Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import isArray from "./isArray";
import isBoolean from "./isBoolean";
import isDate from "./isDate";
import isEmpty from "./isEmpty";
import isMatch from "./isMatch";
import isNil from "./isNil";
import isNull from "./isNull";
import isNumber from "./isNumber";
Expand All @@ -36,6 +37,7 @@ import juxt from "./juxt";
import last from "./last";
import lt from "./lt";
import lte from "./lte";
import matches from "./matches";
import max from "./max";
import memoize from "./memoize";
import min from "./min";
Expand Down Expand Up @@ -100,6 +102,7 @@ export {
isBoolean,
isDate,
isEmpty,
isMatch,
isNil,
isNull,
isNumber,
Expand All @@ -111,6 +114,7 @@ export {
last,
lt,
lte,
matches,
max,
memoize,
min,
Expand Down
101 changes: 101 additions & 0 deletions src/isMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import isNil from "./isNil";

/**
* Performs a partial deep comparison between `object` and `source` to determine
* if `object` contains all property values from `source`.
*
* Used as the internal comparison logic for the `matches` function.
* `source` only needs to match a subset of `object`'s properties.
*
* Supported types: primitives, Object (including nested), Array, Date, RegExp, Map, Set.
*
* @example
* ```ts
* // Partial object matching
* isMatch({ a: 1, b: 2 }, { a: 1 }); // true
* isMatch({ a: 1 }, { a: 1, b: 2 }); // false - object is missing 'b'
*
* // Nested object matching
* isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true
* isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false
*
* // Array matching (must match exactly)
* isMatch([1, 2, 3], [1, 2, 3]); // true
* isMatch([1, 2], [1, 2, 3]); // false
*
* // Special type matching
* isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true
* isMatch(/abc/gi, /abc/gi); // true
*
* // Empty source always returns true
* isMatch({ a: 1, b: 2 }, {}); // true
* ```
*/
function isMatch(object: unknown, source: unknown): boolean {
if (source === object) return true;

if (isNil(object) || isNil(source)) return false;

if (typeof source !== typeof object) return false;

if (typeof source !== "object") return source === object;

if (source instanceof Date && object instanceof Date) {
return source.getTime() === object.getTime();
}

if (source instanceof RegExp && object instanceof RegExp) {
return source.source === object.source && source.flags === object.flags;
}

if (source instanceof Map && object instanceof Map) {
if (source.size !== object.size) return false;
for (const [key, value] of source) {
if (!object.has(key) || !isMatch(object.get(key), value)) {
return false;
}
}
return true;
}

if (source instanceof Set && object instanceof Set) {
if (source.size !== object.size) return false;
for (const value of source) {
let found = false;
for (const objValue of object) {
if (isMatch(objValue, value)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}

if (Array.isArray(source) && Array.isArray(object)) {
if (source.length !== object.length) return false;
for (let i = 0; i < source.length; i++) {
if (!isMatch(object[i], source[i])) return false;
}
return true;
}

if (Array.isArray(source) !== Array.isArray(object)) return false;

const sourceObj = source as Record<string, unknown>;
const objectObj = object as Record<string, unknown>;

for (const key of Object.keys(sourceObj)) {
if (
!Object.prototype.hasOwnProperty.call(objectObj, key) ||
!isMatch(objectObj[key], sourceObj[key])
) {
return false;
}
}

return true;
}

export default isMatch;
69 changes: 69 additions & 0 deletions src/matches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import every from "./every";
import isMatch from "./isMatch";
import isNil from "./isNil";
import { entries } from "./Lazy";
import type Key from "./types/Key";

/**
* Creates a predicate function that checks if an input matches all properties in the given pattern.
*
* Performs deep comparison for nested objects and arrays.
* Useful for replacing callback functions with object patterns in `find`, `filter`, `some`, `every`.
*
* @example
* ```ts
* const users = [
* { name: "John", age: 30, active: true },
* { name: "Jane", age: 25, active: false },
* { name: "Bob", age: 30, active: true },
* ];
*
* // Use with filter
* filter(matches({ age: 30, active: true }), users);
* // [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }]
*
* // Use with find
* find(matches({ active: true }), users);
* // { name: "John", age: 30, active: true }
*
* // Use with pipe
* pipe(users, filter(matches({ active: true })), toArray);
* // [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }]
*
* // Deep matching with nested objects
* const data = [
* { id: 1, user: { profile: { age: 30 } } },
* { id: 2, user: { profile: { age: 25 } } },
* { id: 3, user: { profile: { age: 30 } } },
* ];
* filter(matches({ user: { profile: { age: 30 } } }), data);
* // [{ id: 1, user: { profile: { age: 30 } } }, { id: 3, user: { profile: { age: 30 } } }]
*
* // Array value matching
* const items = [
* { id: 1, tags: ["a", "b"] },
* { id: 2, tags: ["c", "d"] },
* { id: 3, tags: ["a", "b"] },
* ];
* filter(matches({ tags: ["a", "b"] }), items);
* // [{ id: 1, tags: ["a", "b"] }, { id: 3, tags: ["a", "b"] }]
*
* // Returns false for null/undefined input
* const matcher = matches({ a: 1 });
* matcher(null); // false
* matcher(undefined); // false
* ```
*/
function matches<T>(pattern: Record<Key, any>): (input: T) => boolean;

function matches<T>(pattern: Record<keyof T, any>): (input: T) => boolean {
return (input: T): boolean =>
isNil(input)
? false
: every(
([key, value]) => isMatch(input[key as keyof T], value),
entries(pattern),
);
}

export default matches;
116 changes: 116 additions & 0 deletions test/isMatch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { isMatch } from "../src";

describe("isMatch", function () {
describe("primitives", function () {
it("should return true for equal primitives", function () {
expect(isMatch(1, 1)).toBe(true);
expect(isMatch("hello", "hello")).toBe(true);
expect(isMatch(true, true)).toBe(true);
});

it("should return false for different primitives", function () {
expect(isMatch(1, 2)).toBe(false);
expect(isMatch("hello", "world")).toBe(false);
});
});

describe("partial object matching", function () {
it("should return true when object contains all source properties", function () {
expect(isMatch({ a: 1, b: 2 }, { a: 1 })).toBe(true);
expect(isMatch({ a: 1, b: 2, c: 3 }, { a: 1, c: 3 })).toBe(true);
});

it("should return false when object is missing source properties", function () {
expect(isMatch({ a: 1 }, { a: 1, b: 2 })).toBe(false);
});

it("should return false when property values differ", function () {
expect(isMatch({ a: 1 }, { a: 2 })).toBe(false);
});

it("should match nested objects partially", function () {
const object = { user: { name: "John", age: 30 } };
expect(isMatch(object, { user: { name: "John" } })).toBe(true);
});

it("should return false for non-matching nested objects", function () {
const object = { user: { name: "John", age: 30 } };
expect(isMatch(object, { user: { name: "Jane" } })).toBe(false);
});

it("should match deeply nested objects", function () {
const object = {
level1: {
level2: {
level3: { value: "deep" },
},
},
};
expect(
isMatch(object, { level1: { level2: { level3: { value: "deep" } } } }),
).toBe(true);
});
});

describe("array matching", function () {
it("should return true for equal arrays", function () {
expect(isMatch([1, 2, 3], [1, 2, 3])).toBe(true);
});

it("should return false for different arrays", function () {
expect(isMatch([1, 2, 3], [1, 2, 4])).toBe(false);
expect(isMatch([1, 2], [1, 2, 3])).toBe(false);
expect(isMatch([1, 2, 3], [1, 2])).toBe(false);
});

it("should match arrays with objects", function () {
expect(isMatch([{ a: 1 }], [{ a: 1 }])).toBe(true);
});
});

describe("special types", function () {
it("should match Date objects", function () {
expect(isMatch(new Date("2024-01-01"), new Date("2024-01-01"))).toBe(
true,
);
expect(isMatch(new Date("2024-01-01"), new Date("2024-02-01"))).toBe(
false,
);
});

it("should match RegExp objects", function () {
expect(isMatch(/abc/gi, /abc/gi)).toBe(true);
expect(isMatch(/abc/gi, /abc/g)).toBe(false);
});

it("should match Map objects", function () {
const map1 = new Map([["a", 1]]);
const map2 = new Map([["a", 1]]);
expect(isMatch(map1, map2)).toBe(true);
});

it("should match Set objects", function () {
const set1 = new Set([1, 2, 3]);
const set2 = new Set([1, 2, 3]);
expect(isMatch(set1, set2)).toBe(true);
});
});

describe("null and undefined", function () {
it("should return false when object is null or undefined", function () {
expect(isMatch(null, { a: 1 })).toBe(false);
expect(isMatch(undefined, { a: 1 })).toBe(false);
});

it("should return false when source is null or undefined", function () {
expect(isMatch({ a: 1 }, null)).toBe(false);
expect(isMatch({ a: 1 }, undefined)).toBe(false);
});
});

describe("empty pattern", function () {
it("should return true for empty source object", function () {
expect(isMatch({ a: 1 }, {})).toBe(true);
});
});
});
Loading
Loading