Repository: remeda/remeda (ts)

Issue:
[ Feature Request ] Lodash "matches" function (Also typing "equals")
I'm looking at using a more TypeScript-oriented utility library after using Lodash. This library seems very nice. However, there does not seem to be a way to check if one object is a subset of another (?). This is pretty common functionality in Lodash like`_.find(data, {a: "b"})`. Right now, one could do `R.find(data, ({a}) => a === "b")`; however, when there are more properties it becomes more burdensome to write out the callback. I realize that you could also do something like `R.find(data, R.createPipe(R.pick(['a']), R.equals({a: "b"})))`, but this is also pretty verbose.

To be clear, I'm not looking for the "iteratees" notation, which I don't think makes sense with typings, but rather just the "matches" function: https://lodash.com/docs/4.17.15#matches. This would be similar to the R.prop function in terms of usefulness. So the example above would become `_.find(data, R.matches({a: "b"}))`.

I also took a look at the R.equals implementation to see if maybe that could be used (doesn't seem so), and I noticed the typing permitted comparing any type to any type. I can see why this makes sense, but I do think it is somewhat of a logical error to compare two things of different types and I would want the compiler to warn about it. If it is intended by the programmer, then there are ways to coerce the types to get around the type check. Maybe it would make sense to have an "R.equals.typed" function with the same implementation but a different typing?

The typing I would like for a "matches" function:
```ts
export function matches<U extends string | number | symbol, V, T extends Record<U, V>>(
  subset: T
) {
  return <S extends T>(object: S) => {
    ...
    return true
  }
}
``` 
This allows for the compiler to error when doing something like `matches({ b: 1 })({ a: 3, b: "a" })` or `matches({ c: 1 })({ a: 3, b: "a" })`.

Pull request description:
feat(hasSubObject): Add `hasSubObject`
closes #386 (at least the function request part)

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `mapping.md`

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


Patch:
diff --git a/mapping.md b/mapping.md
index d2ba6e1..6b5e7dc 100644
--- a/mapping.md
+++ b/mapping.md
@@ -41,6 +41,7 @@ documentation when migrating._
 | [`forEach`](https://remedajs.com/docs/#forEach)                     | [`forEach`](https://lodash.com/docs/4.17.15#forEach)                     | [`forEach`](https://ramdajs.com/docs/#forEach)                     |
 | [`forEachObj`](https://remedajs.com/docs/#forEachObj)               | [`forEach`](https://lodash.com/docs/4.17.15#forEach)                     | [`forEachObjIndexed`](https://ramdajs.com/docs/#forEachObjIndexed) |
 | [`groupBy`](https://remedajs.com/docs/#groupBy)                     | [`groupBy`](https://lodash.com/docs/4.17.15#groupBy)                     | [`groupBy`](https://ramdajs.com/docs/#groupBy)                     |
+| [`hasSubobject`](https://remedajs.com/docs/#hasSubobject)           | [`isMatch`](https://lodash.com/docs/4.17.15#isMatch)                     | [`where`](https://ramdajs.com/docs/#where)                         |
 | [`identity`](https://remedajs.com/docs/#identity)                   | [`identity`](https://lodash.com/docs/4.17.15#identity)                   | [`identity`](https://ramdajs.com/docs/#identity)                   |
 | [`indexBy`](https://remedajs.com/docs/#indexBy)                     | [`keyBy`](https://lodash.com/docs/4.17.15#keyBy)                         | [`indexBy`](https://ramdajs.com/docs/#indexBy)                     |
 | [`intersection`](https://remedajs.com/docs/#intersection)           | [`intersection`](https://lodash.com/docs/4.17.15#intersection)           | [`intersection`](https://ramdajs.com/docs/#intersection)           |
diff --git a/src/hasSubObject.ts b/src/hasSubObject.ts
new file mode 100644
index 0000000..9fc4b39
--- /dev/null
+++ b/src/hasSubObject.ts
@@ -0,0 +1,65 @@
+import { isDeepEqual } from "./isDeepEqual";
+import { purry } from "./purry";
+import type { Simplify } from "./type-fest/simplify";
+
+/**
+ * Checks if `subObject` is a sub-object of `object`, which means for every
+ * property and value in `subObject`, there's the same property in `object`
+ * with an equal value. Equality is checked with `isDeepEqual`.
+ *
+ * @param data - The object to test.
+ * @param subObject - The sub-object to test against.
+ * @signature
+ *    R.hasSubObject(data, subObject)
+ * @example
+ *    R.hasSubObject({ a: 1, b: 2, c: 3 }, { a: 1, c: 3 }) //=> true
+ *    R.hasSubObject({ a: 1, b: 2, c: 3 }, { b: 4 }) //=> false
+ *    R.hasSubObject({ a: 1, b: 2, c: 3 }, {}) //=> true
+ * @dataFirst
+ * @category Guard
+ */
+export function hasSubObject<T, S extends Partial<T>>(
+  data: T,
+  subObject: S,
+): // TODO: want to use data is Merge<T, S> here, but it's a typescript error. fix after we allow @ts-expect-error in the build process
+data is Simplify<S & T>;
+
+/**
+ * Checks if `subObject` is a sub-object of `object`, which means for every
+ * property and value in `subObject`, there's the same property in `object`
+ * with an equal value. Equality is checked with `isDeepEqual`.
+ *
+ * @param subObject - The sub-object to test against.
+ * @signature
+ *    R.hasSubObject(subObject)(data)
+ * @example
+ *    R.hasSubObject({ a: 1, c: 3 })({ a: 1, b: 2, c: 3 }) //=> true
+ *    R.hasSubObject({ b: 4 })({ a: 1, b: 2, c: 3 }) //=> false
+ *    R.hasSubObject({})({ a: 1, b: 2, c: 3 }) //=> true
+ * @dataLast
+ * @category Guard
+ */
+export function hasSubObject<T, S extends Partial<T>>(
+  subObject: S,
+): (data: T) => data is Simplify<S & T>;
+
+export function hasSubObject(): unknown {
+  return purry(_hasSubObject, arguments);
+}
+
+function _hasSubObject<T, S extends Partial<T>>(
+  data: T,
+  subObject: S,
+): data is Simplify<S & T> {
+  for (const key of Object.keys(subObject)) {
+    if (!Object.prototype.hasOwnProperty.call(data, key)) {
+      return false;
+    }
+
+    // @ts-expect-error [ts7053] - key is in both subObject and data:
+    if (!isDeepEqual(subObject[key], data[key])) {
+      return false;
+    }
+  }
+  return true;
+}
diff --git a/src/index.ts b/src/index.ts
index c556fa3..49e46cb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -39,6 +39,7 @@ export * from "./forEachObj";
 export * from "./fromPairs";
 export * from "./groupBy";
 export * from "./hasAtLeast";
+export * from "./hasSubObject";
 export * from "./identity";
 export * from "./indexBy";
 export * from "./intersection";


Test patch:
diff --git a/src/hasSubObject.test.ts b/src/hasSubObject.test.ts
new file mode 100644
index 0000000..2cecea9
--- /dev/null
+++ b/src/hasSubObject.test.ts
@@ -0,0 +1,148 @@
+import { hasSubObject } from "./hasSubObject";
+import { pipe } from "./pipe";
+
+describe("data first", () => {
+  test("works with empty sub-object", () => {
+    expect(hasSubObject({ a: 1, b: "b", c: 3 }, {})).toBe(true);
+    expect(hasSubObject({}, {})).toBe(true);
+  });
+
+  test("works with primitives", () => {
+    expect(hasSubObject({ a: 1, b: "b", c: 3 }, { a: 1, b: "b" })).toBe(true);
+    expect(hasSubObject({ a: 1, b: "c", c: 3 }, { a: 1, b: "b" })).toBe(false);
+    expect(hasSubObject({ a: 2, b: "b", c: 3 }, { a: 1, b: "b" })).toBe(false);
+  });
+
+  test("works with deep objects", () => {
+    expect(hasSubObject({ a: { b: 1, c: 2 } }, { a: { b: 1, c: 2 } })).toBe(
+      true,
+    );
+    expect(hasSubObject({ a: { b: 1, c: 2 } }, { a: { b: 1, c: 0 } })).toBe(
+      false,
+    );
+  });
+
+  test("checks for matching key", () => {
+    const data = {} as { a?: undefined };
+    expect(hasSubObject(data, { a: undefined })).toBe(false);
+  });
+});
+
+describe("data last", () => {
+  test("empty sub-object", () => {
+    expect(pipe({ a: 1, b: 2, c: 3 }, hasSubObject({}))).toBe(true);
+    expect(pipe({}, hasSubObject({}))).toBe(true);
+  });
+
+  test("works with primitives", () => {
+    expect(pipe({ a: 1, b: "b", c: 3 }, hasSubObject({ a: 1, b: "b" }))).toBe(
+      true,
+    );
+    expect(pipe({ a: 1, b: "c", c: 3 }, hasSubObject({ a: 1, b: "b" }))).toBe(
+      false,
+    );
+    expect(pipe({ a: 2, b: "b", c: 3 }, hasSubObject({ a: 1, b: "b" }))).toBe(
+      false,
+    );
+  });
+
+  test("works with deep objects", () => {
+    expect(
+      pipe({ a: { b: 1, c: 2 } }, hasSubObject({ a: { b: 1, c: 2 } })),
+    ).toBe(true);
+
+    expect(
+      pipe({ a: { b: 1, c: 2 } }, hasSubObject({ a: { b: 1, c: 3 } })),
+    ).toBe(false);
+  });
+});
+
+describe("typing", () => {
+  describe("data-first", () => {
+    test("must have matching keys and values", () => {
+      expectTypeOf(hasSubObject({ a: 2 }, { a: 1 })).toEqualTypeOf<boolean>();
+
+      // @ts-expect-error [ts2353] - missing a key
+      hasSubObject({ b: 2 }, { a: 1 });
+
+      // @ts-expect-error [ts2322] - different value type
+      hasSubObject({ a: "a" }, { a: 1 });
+
+      // ok - wider value type
+      hasSubObject({ a: "a" } as { a: number | string }, { a: 1 });
+
+      // @ts-expect-error [ts2345] - narrower value type
+      hasSubObject({ a: "a" }, { a: 1 } as { a: number | string });
+
+      // ok - unknown data type
+      hasSubObject({ a: 2 } as unknown, { a: 1 });
+
+      // ok - const value
+      hasSubObject({ a: 2 }, { a: 1 } as const);
+    });
+
+    test("allows nested objects", () => {
+      // ok - nested object has extra keys
+      hasSubObject({ a: { b: 4 } }, { a: { b: 1, c: 2 } });
+
+      // @ts-expect-error [ts2741] - nested object has missing keys
+      hasSubObject({ a: { b: 1, c: 2 } }, { a: { b: 1 } });
+
+      // @ts-expect-error [ts2322] - nested object has wrong value types
+      hasSubObject({ a: { b: 4, c: "c" } }, { a: { b: 1, c: 2 } });
+    });
+
+    test("narrows type", () => {
+      const obj = {} as { a?: string; b?: number };
+
+      if (hasSubObject(obj, {})) {
+        expectTypeOf(obj).toEqualTypeOf<{ a?: string; b?: number }>();
+      }
+
+      if (hasSubObject(obj, { a: "a" })) {
+        expectTypeOf(obj).toEqualTypeOf<{ a: string; b?: number }>();
+      }
+
+      if (hasSubObject(obj, { a: "a" } as const)) {
+        expectTypeOf(obj).toEqualTypeOf<{ a: "a"; b?: number }>();
+      }
+    });
+  });
+
+  describe("data-last", () => {
+    test("must have matching keys and values", () => {
+      expectTypeOf(
+        pipe({ a: 2 }, hasSubObject({ a: 1 })),
+      ).toEqualTypeOf<boolean>();
+
+      // @ts-expect-error [ts2353] - missing a key
+      pipe({ b: 2 }, hasSubObject({ a: 1 }));
+
+      // @ts-expect-error [ts2345] - different value type
+      pipe({ a: "a" }, hasSubObject({ a: 1 }));
+
+      // ok - wider value type
+      pipe({ a: "a" } as { a: number | string }, hasSubObject({ a: 1 }));
+
+      // @ts-expect-error [ts2345] - narrower value type
+      pipe({ a: "a" }, hasSubObject({ a: 1 } as { a: number | string }));
+
+      // ok - unknown data type
+      pipe({ a: 2 } as unknown, hasSubObject({ a: 1 }));
+
+      // ok - const value
+      pipe({ a: 2 }, hasSubObject({ a: 1 } as const));
+    });
+
+    test("allows nested objects", () => {
+      // ok - nested object has extra keys
+      pipe({ a: { b: 4 } }, hasSubObject({ a: { b: 1, c: 2 } }));
+
+      // @ts-expect-error [ts2345] - nested object has missing keys
+      pipe({ a: { b: 1, c: 2 } }, hasSubObject({ a: { b: 1 } }));
+
+      // @ts-expect-error [ts2345] - nested object has wrong value types
+      pipe({ a: { b: 4, c: "c" } }, hasSubObject({ a: { b: 1, c: 2 } }));
+    });
+  });
+});