Skip to content
Merged
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
142 changes: 142 additions & 0 deletions src/isMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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 (partial, prefix-based)
* isMatch([1, 2, 3], [1, 2, 3]); // true
* isMatch([1, 2, 3], [1, 2]); // true
* isMatch([1, 2], [1, 2, 3]); // false
*
* // Map matching
* const map1 = new Map([["key1", "value1"], ["key2", "value2"]]);
* const map2 = new Map([["key1", "value1"]]);
* isMatch(map1, map2); // true
*
* // Set matching
* const set1 = new Set([1, 2, 3]);
* const set2 = new Set([1, 2]);
* isMatch(set1, set2); // true
*
* // Date type matching
* const now = Date.now();
* isMatch(new Date(now), new Date(now)); // true
*
* // RegExp type matching
* 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 || (source !== source && object !== object);
}

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

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

if (source instanceof Map || object instanceof Map) {
if (!(source instanceof Map && object instanceof Map)) return false;
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 instanceof Set && object instanceof Set)) return false;
if (source.size > object.size) return false;
const sourceValues = Array.from(source);
const objectValues = Array.from(object);
const assignedSource = new Array<number>(objectValues.length).fill(-1);

const findMatchingPath = (
sourceIndex: number,
visited: Set<number>,
): boolean => {
for (
let objectIndex = 0;
objectIndex < objectValues.length;
objectIndex++
) {
if (visited.has(objectIndex)) continue;
if (!isMatch(objectValues[objectIndex], sourceValues[sourceIndex]))
continue;
visited.add(objectIndex);
if (
assignedSource[objectIndex] === -1 ||
findMatchingPath(assignedSource[objectIndex], visited)
) {
assignedSource[objectIndex] = sourceIndex;
return true;
}
}
return false;
};

for (let si = 0; si < sourceValues.length; si++) {
if (!findMatchingPath(si, new Set())) 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;
148 changes: 148 additions & 0 deletions src/matches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import isMatch from "./isMatch";
import isNil from "./isNil";

type DeepPartial<T> = T extends Date | RegExp | Map<any, any> | Set<any>
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type Widen<T> = T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T;
type DeepWiden<T> = T extends Date | RegExp | Map<any, any> | Set<any>
? T
: T extends Array<infer U>
? Array<DeepWiden<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepWiden<U>>
: T extends object
? { [K in keyof T]: DeepWiden<T[K]> }
: Widen<T>;
type RejectSymbolKeys<T> = T extends Date | RegExp | Map<any, any> | Set<any>
? T
: T extends object
? {
[K in keyof T]: K extends symbol
? never
: T[K] extends object
? RejectSymbolKeys<T[K]>
: T[K];
}
: T;

const isPlainObjectPattern = (pattern: object): boolean =>
!(
pattern instanceof Date ||
pattern instanceof RegExp ||
pattern instanceof Map ||
pattern instanceof Set ||
Array.isArray(pattern)
);

/**
* 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
* // ~~~~
* // Error: Argument of type null is not assignable to parameter
* matcher(undefined); // false
* // ~~~~~~~~~~
* // Error: Argument of type 'undefined' is not assignable to parameter
* ```
*
* Symbol keys in the pattern are silently ignored at runtime. (which only enumerates string-keyed (and numeric-keyed) properties.)
*
* ```ts
* const sym = Symbol("id");
* // matches({ [sym]: 123 });
* // ~~~~
* // Error: Argument of type { [sym]: number } is not assignable to parameter
*
* // If bypassed at runtime, the symbol key is ignored:
* const pattern = { [sym]: 123 } as any;
* matches(pattern)({}); // true — symbol key was not compared
* ```
*/
function matches<T>(
pattern: DeepPartial<T> & RejectSymbolKeys<T>,
): (input: DeepWiden<T>) => boolean;

function matches(
pattern: Record<string | number, any>,
): (input: any) => boolean {
// Special-type / primitive patterns (Date, RegExp, Map, Set, Array, nil,
// primitives) have no enumerable string keys to compare key-by-key, so
// delegate to `isMatch`, which compares them by value.
if (
isNil(pattern) ||
typeof pattern !== "object" ||
!isPlainObjectPattern(pattern)
) {
return (input) => (isNil(input) ? false : isMatch(input, pattern));
}

// Plain-object pattern: the keys are fixed, so enumerate them once here
// instead of on every predicate call (the hot path of `filter`/`find`/...).
const keys = Object.keys(pattern);
return (input) => {
if (isNil(input)) return false;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!isMatch(input[key], pattern[key])) return false;
}
return true;
};
}

export default matches;
Loading
Loading