From 0a19c8a92710974a095fe7917d40aac194a0b35d Mon Sep 17 00:00:00 2001 From: boy672820 Date: Tue, 17 Feb 2026 01:30:11 +0900 Subject: [PATCH 1/8] feat: add isMatch function --- src/index.ts | 2 + src/isMatch.ts | 101 ++++++++++++++++++++++++++++++++ test/isMatch.spec.ts | 116 +++++++++++++++++++++++++++++++++++++ type-check/isMatch.test.ts | 32 ++++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/isMatch.ts create mode 100644 test/isMatch.spec.ts create mode 100644 type-check/isMatch.test.ts diff --git a/src/index.ts b/src/index.ts index d93d5b30e..ec97f98e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -100,6 +101,7 @@ export { isBoolean, isDate, isEmpty, + isMatch, isNil, isNull, isNumber, diff --git a/src/isMatch.ts b/src/isMatch.ts new file mode 100644 index 000000000..bbfb0a108 --- /dev/null +++ b/src/isMatch.ts @@ -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; + const objectObj = object as Record; + + 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; diff --git a/test/isMatch.spec.ts b/test/isMatch.spec.ts new file mode 100644 index 000000000..9ca5c5460 --- /dev/null +++ b/test/isMatch.spec.ts @@ -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); + }); + }); +}); diff --git a/type-check/isMatch.test.ts b/type-check/isMatch.test.ts new file mode 100644 index 000000000..6d1b79c77 --- /dev/null +++ b/type-check/isMatch.test.ts @@ -0,0 +1,32 @@ +import { isMatch } from "../src"; +import * as Test from "../src/types/Test"; + +const { checks, check } = Test; + +const res1 = isMatch({ a: 1, b: 2 }, { a: 1 }); +const res2 = isMatch({ a: 1 }, { a: 1, b: 2 }); +const res3 = isMatch({ user: { name: "John" } }, { user: { name: "John" } }); +const res4 = isMatch([1, 2, 3], [1, 2, 3]); +const res5 = isMatch(new Date("2024-01-01"), new Date("2024-01-01")); +const res6 = isMatch(/abc/gi, /abc/gi); +const res7 = isMatch({ a: 1 }, {}); +const res8 = isMatch(null, { a: 1 }); +const res9 = isMatch({ a: 1 }, null); + +const res10 = isMatch(new Map([["a", 1]]), new Map([["a", 1]])); + +const res11 = isMatch(new Set([1, 2, 3]), new Set([1, 2, 3])); + +checks([ + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), +]); From cbb05e0000b54c50c6784ec36dfe514709b9df66 Mon Sep 17 00:00:00 2001 From: boy672820 Date: Tue, 17 Feb 2026 01:30:46 +0900 Subject: [PATCH 2/8] feat: add matches function --- src/index.ts | 2 + src/matches.ts | 69 +++++++++++ test/matches.spec.ts | 233 +++++++++++++++++++++++++++++++++++++ type-check/matches.test.ts | 61 ++++++++++ 4 files changed, 365 insertions(+) create mode 100644 src/matches.ts create mode 100644 test/matches.spec.ts create mode 100644 type-check/matches.test.ts diff --git a/src/index.ts b/src/index.ts index ec97f98e2..04ec63443 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,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"; @@ -113,6 +114,7 @@ export { last, lt, lte, + matches, max, memoize, min, diff --git a/src/matches.ts b/src/matches.ts new file mode 100644 index 000000000..5c616897e --- /dev/null +++ b/src/matches.ts @@ -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(pattern: Record): (input: T) => boolean; + +function matches(pattern: Record): (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; diff --git a/test/matches.spec.ts b/test/matches.spec.ts new file mode 100644 index 000000000..d060a34e8 --- /dev/null +++ b/test/matches.spec.ts @@ -0,0 +1,233 @@ +import { every, filter, find, fx, map, matches, pipe, some } from "../src"; + +describe("matches", function () { + describe("basic matching", function () { + it("should return true when all properties match", function () { + const matcher = matches({ a: 1, b: "2", c: true }); + + expect(matcher({ a: 1, b: "2", c: true, d: null })).toBe(true); + }); + + it("should return false when any property does not match", function () { + const matcher = matches({ a: 1, b: "2", c: true }); + + expect(matcher({ a: 1, b: "2", c: false })).toBe(false); + }); + + it("should return false when property is missing", function () { + const matcher = matches({ a: 1, b: "2" }); + + expect(matcher({ a: 1 })).toBe(false); + }); + + it("should return true for empty pattern", function () { + const matcher = matches({}); + + expect(matcher({ a: 1 })).toBe(true); + }); + + it("should return false for null input", function () { + const matcher = matches({ a: 1 }); + + expect(matcher(null)).toBe(false); + }); + + it("should return false for undefined input", function () { + const matcher = matches({ a: 1 }); + + expect(matcher(undefined)).toBe(false); + }); + + it("should be able to used as a curried function in the pipeline", function () { + const result = pipe( + { a: 1, b: "2", c: true, d: null }, + matches({ a: 1, b: "2" }), + ); + + expect(result).toBe(true); + }); + }); + + describe("find", function () { + const users = [ + { id: 1, name: "John", age: 30 }, + { id: 2, name: "Jane", age: 25 }, + { id: 3, name: "Bob", age: 30 }, + ]; + + it("should work with find", function () { + const result = find(matches({ age: 30 }), users); + + expect(result).toEqual({ id: 1, name: "John", age: 30 }); + }); + + it("should return undefined when not found", function () { + const result = find(matches({ age: 40 }), users); + + expect(result).toBeUndefined(); + }); + + it("should be able to be used as a curried function in the pipeline", function () { + const result = pipe(users, find(matches({ name: "Jane" }))); + + expect(result).toEqual({ id: 2, name: "Jane", age: 25 }); + }); + + it("should be able to be used as a chaining method in the `fx`", function () { + const result = fx(users).find(matches({ id: 3 })); + + expect(result).toEqual({ id: 3, name: "Bob", age: 30 }); + }); + }); + + describe("filter", function () { + const users = [ + { id: 1, name: "John", age: 30, active: true }, + { id: 2, name: "Jane", age: 25, active: false }, + { id: 3, name: "Bob", age: 30, active: true }, + ]; + + it("should work with filter in pipeline", function () { + const result = pipe( + users, + filter(matches({ age: 30 })), + map((u) => u.name), + ); + + expect([...result]).toEqual(["John", "Bob"]); + }); + + it("should work with filter in fx chain", function () { + const result = fx(users) + .filter(matches({ active: true })) + .map((u) => u.id); + + expect([...result]).toEqual([1, 3]); + }); + }); + + describe("some", function () { + const users = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: false }, + { name: "Bob", age: 30, active: true }, + ]; + + it("should return true when at least one matches", function () { + const result = some(matches({ age: 25 }), users); + + expect(result).toBe(true); + }); + + it("should return false when none match", function () { + const result = some(matches({ age: 40 }), users); + + expect(result).toBe(false); + }); + + it("should work in pipeline", function () { + const result = pipe(users, some(matches({ name: "Bob", active: true }))); + + expect(result).toBe(true); + }); + }); + + describe("every", function () { + const users = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: true }, + { name: "Bob", age: 30, active: true }, + ]; + + it("should return true when all match", function () { + const result = every(matches({ active: true }), users); + + expect(result).toBe(true); + }); + + it("should return false when not all match", function () { + const result = every(matches({ age: 30 }), users); + + expect(result).toBe(false); + }); + + it("should work in pipeline", function () { + const result = pipe(users, every(matches({ active: true }))); + + expect(result).toBe(true); + }); + }); + + describe("deep matching", function () { + it("should match nested objects", function () { + const data = [ + { id: 1, user: { name: "John", profile: { age: 30 } } }, + { id: 2, user: { name: "Jane", profile: { age: 25 } } }, + { id: 3, user: { name: "Bob", profile: { age: 30 } } }, + ]; + + const result = filter(matches({ user: { profile: { age: 30 } } }), data); + + expect([...result]).toEqual([ + { id: 1, user: { name: "John", profile: { age: 30 } } }, + { id: 3, user: { name: "Bob", profile: { age: 30 } } }, + ]); + }); + + it("should match arrays", function () { + const data = [ + { id: 1, tags: ["a", "b"] }, + { id: 2, tags: ["c", "d"] }, + { id: 3, tags: ["a", "b"] }, + ]; + + const result = filter(matches({ tags: ["a", "b"] }), data); + + expect([...result]).toEqual([ + { id: 1, tags: ["a", "b"] }, + { id: 3, tags: ["a", "b"] }, + ]); + }); + + it("should match nested arrays with objects", function () { + const data = [ + { id: 1, items: [{ x: 1 }, { x: 2 }] }, + { id: 2, items: [{ x: 3 }, { x: 4 }] }, + { id: 3, items: [{ x: 1 }, { x: 2 }] }, + ]; + + const result = filter(matches({ items: [{ x: 1 }, { x: 2 }] }), data); + + expect([...result]).toEqual([ + { id: 1, items: [{ x: 1 }, { x: 2 }] }, + { id: 3, items: [{ x: 1 }, { x: 2 }] }, + ]); + }); + + it("should return false for non-matching nested objects", function () { + const matcher = matches({ user: { name: "John" } }); + + expect(matcher({ user: { name: "Jane" } })).toBe(false); + }); + + it("should return false for non-matching arrays", function () { + const matcher = matches({ tags: ["a", "b"] }); + + expect(matcher({ tags: ["a", "c"] })).toBe(false); + expect(matcher({ tags: ["a"] })).toBe(false); + expect(matcher({ tags: ["a", "b", "c"] })).toBe(false); + }); + + it("should match Date objects", function () { + const date = new Date("2024-01-01"); + const data = [ + { id: 1, created: new Date("2024-01-01") }, + { id: 2, created: new Date("2024-02-01") }, + ]; + + const result = find(matches({ created: date }), data); + + expect(result).toEqual({ id: 1, created: new Date("2024-01-01") }); + }); + }); +}); diff --git a/type-check/matches.test.ts b/type-check/matches.test.ts new file mode 100644 index 000000000..d48f621be --- /dev/null +++ b/type-check/matches.test.ts @@ -0,0 +1,61 @@ +import { filter, find, matches, pipe, toArray } from "../src"; +import * as Test from "../src/types/Test"; + +const { checks, check } = Test; + +type User = { + name: string; + age: number; + active: boolean; +}; + +const users: User[] = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: false }, + { name: "Bob", age: 30, active: true }, +]; + +const res1 = matches({ age: 30 }); +const res2 = matches({ age: 30, active: true }); +const res3 = matches({}); + +const res4 = res1({ name: "John", age: 30, active: true }); +const res5 = res1(null); +const res6 = res1(undefined); + +const res7 = filter(matches({ age: 30 }), users); +const res8 = find(matches({ active: true }), users); + +const res9 = pipe(users, filter(matches({ age: 30 })), toArray); +const res10 = pipe(users, find(matches({ name: "John" }))); + +type NestedUser = { + id: number; + profile: { + name: string; + age: number; + }; +}; + +const nestedUsers: NestedUser[] = [ + { id: 1, profile: { name: "John", age: 30 } }, + { id: 2, profile: { name: "Jane", age: 25 } }, +]; + +const res11 = filter(matches({ profile: { age: 30 } }), nestedUsers); +const res12 = find(matches({ profile: { name: "John" } }), nestedUsers); + +checks([ + check boolean, Test.Pass>(), + check boolean, Test.Pass>(), + check boolean, Test.Pass>(), + check(), + check(), + check(), + check, Test.Pass>(), + check(), + check(), + check(), + check, Test.Pass>(), + check(), +]); From 99cb8ff01ee2fdbe43e1bb1a7b5c00636b32a2e7 Mon Sep 17 00:00:00 2001 From: boy672820 Date: Tue, 17 Feb 2026 01:47:26 +0900 Subject: [PATCH 3/8] feat: add matches and isMatch spec description --- website/docs/.vitepress/function.json | 2 + website/docs/ja/api/index.md | 2 + website/docs/ja/api/isMatch.md | 45 +++++++++++++++++ website/docs/ja/api/matches.md | 65 +++++++++++++++++++++++++ website/docs/ko/api/index.md | 2 + website/docs/ko/api/isMatch.md | 45 +++++++++++++++++ website/docs/ko/api/matches.md | 65 +++++++++++++++++++++++++ website/docs/zh/api/index.md | 2 + website/docs/zh/api/isMatch.md | 43 ++++++++++++++++ website/docs/zh/api/matches.md | 63 ++++++++++++++++++++++++ website/generate-api-docs/function.json | 2 + 11 files changed, 336 insertions(+) create mode 100644 website/docs/ja/api/isMatch.md create mode 100644 website/docs/ja/api/matches.md create mode 100644 website/docs/ko/api/isMatch.md create mode 100644 website/docs/ko/api/matches.md create mode 100644 website/docs/zh/api/isMatch.md create mode 100644 website/docs/zh/api/matches.md diff --git a/website/docs/.vitepress/function.json b/website/docs/.vitepress/function.json index 59be201e7..dfb501ea6 100644 --- a/website/docs/.vitepress/function.json +++ b/website/docs/.vitepress/function.json @@ -76,6 +76,7 @@ "isBoolean", "isDate", "isEmpty", + "isMatch", "isNil", "isNumber", "isObject", @@ -86,6 +87,7 @@ "last", "lt", "lte", + "matches", "max", "memoize", "min", diff --git a/website/docs/ja/api/index.md b/website/docs/ja/api/index.md index 4d36b39a6..7043e4e44 100644 --- a/website/docs/ja/api/index.md +++ b/website/docs/ja/api/index.md @@ -81,6 +81,7 @@ id: index - [isBoolean](/ja/api/isBoolean) - [isDate](/ja/api/isDate) - [isEmpty](/ja/api/isEmpty) +- [isMatch](/ja/api/isMatch) - [isNil](/ja/api/isNil) - [isNumber](/ja/api/isNumber) - [isObject](/ja/api/isObject) @@ -91,6 +92,7 @@ id: index - [last](/ja/api/last) - [lt](/ja/api/lt) - [lte](/ja/api/lte) +- [matches](/ja/api/matches) - [max](/ja/api/max) - [memoize](/ja/api/memoize) - [min](/ja/api/min) diff --git a/website/docs/ja/api/isMatch.md b/website/docs/ja/api/isMatch.md new file mode 100644 index 000000000..2f9545cad --- /dev/null +++ b/website/docs/ja/api/isMatch.md @@ -0,0 +1,45 @@ +--- +id: isMatch +--- + + + +## isMatch() function + +`object` と `source` の間で部分的な深い比較(partial deep comparison)を行い、`object` が `source` のすべてのプロパティ値を含んでいるかどうかを確認します。 + +`matches` 関数の内部比較ロジックとして使用されます。 +`source` は `object` のプロパティのサブセットのみ一致すれば十分です。 + +対応型: プリミティブ型、Object(ネストを含む)、Array、Date、RegExp、Map、Set。 + +**Signature:** + +```typescript +function isMatch(object: unknown, source: unknown): boolean; +``` + +## Example + +```ts +// 部分オブジェクトマッチング +isMatch({ a: 1, b: 2 }, { a: 1 }); // true +isMatch({ a: 1 }, { a: 1, b: 2 }); // false - objectに'b'がない + +// ネストされたオブジェクトのマッチング +isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true +isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false + +// 配列マッチング(完全に一致する必要がある) +isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2], [1, 2, 3]); // false + +// 特殊型のマッチング +isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +isMatch(/abc/gi, /abc/gi); // true + +// 空のsourceは常にtrue +isMatch({ a: 1, b: 2 }, {}); // true +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/isMatch.ts) diff --git a/website/docs/ja/api/matches.md b/website/docs/ja/api/matches.md new file mode 100644 index 000000000..f9bd34669 --- /dev/null +++ b/website/docs/ja/api/matches.md @@ -0,0 +1,65 @@ +--- +id: matches +--- + + + +## matches() function + +指定されたパターンのすべてのプロパティに一致するかどうかを確認する述語(predicate)関数を生成します。 + +ネストされたオブジェクトと配列に対して深い比較を行います。 +`find`、`filter`、`some`、`every` でコールバック関数をオブジェクトパターンに置き換える際に便利です。 + +**Signature:** + +```typescript +function matches(pattern: Record): (input: T) => boolean; +``` + +## Example + +```ts +const users = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: false }, + { name: "Bob", age: 30, active: true }, +]; + +// filterと一緒に使用 +filter(matches({ age: 30, active: true }), users); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// findと一緒に使用 +find(matches({ active: true }), users); +// { name: "John", age: 30, active: true } + +// pipeと一緒に使用 +pipe(users, filter(matches({ active: true })), toArray); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// ネストされたオブジェクトのマッチング +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 } } }] + +// 配列値のマッチング +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"] }] + +// null/undefined 入力に対して false を返す +const matcher = matches({ a: 1 }); +matcher(null); // false +matcher(undefined); // false +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) diff --git a/website/docs/ko/api/index.md b/website/docs/ko/api/index.md index 74e714d6b..b843513f6 100644 --- a/website/docs/ko/api/index.md +++ b/website/docs/ko/api/index.md @@ -81,6 +81,7 @@ id: index - [isBoolean](/ko/api/isBoolean) - [isDate](/ko/api/isDate) - [isEmpty](/ko/api/isEmpty) +- [isMatch](/ko/api/isMatch) - [isNil](/ko/api/isNil) - [isNumber](/ko/api/isNumber) - [isObject](/ko/api/isObject) @@ -91,6 +92,7 @@ id: index - [last](/ko/api/last) - [lt](/ko/api/lt) - [lte](/ko/api/lte) +- [matches](/ko/api/matches) - [max](/ko/api/max) - [memoize](/ko/api/memoize) - [min](/ko/api/min) diff --git a/website/docs/ko/api/isMatch.md b/website/docs/ko/api/isMatch.md new file mode 100644 index 000000000..6f6db30b6 --- /dev/null +++ b/website/docs/ko/api/isMatch.md @@ -0,0 +1,45 @@ +--- +id: isMatch +--- + + + +## isMatch() function + +`object`와 `source` 간의 부분 깊은 비교(partial deep comparison)를 수행하여, `object`가 `source`의 모든 속성 값을 포함하는지 확인합니다. + +`matches` 함수의 내부 비교 로직으로 사용됩니다. +`source`는 `object` 속성의 부분 집합(subset)만 일치하면 됩니다. + +지원 타입: 원시 타입, Object (중첩 포함), Array, Date, RegExp, Map, Set. + +**Signature:** + +```typescript +function isMatch(object: unknown, source: unknown): boolean; +``` + +## Example + +```ts +// 부분 객체 매칭 +isMatch({ a: 1, b: 2 }, { a: 1 }); // true +isMatch({ a: 1 }, { a: 1, b: 2 }); // false - object에 'b'가 없음 + +// 중첩 객체 매칭 +isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true +isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false + +// 배열 매칭 (정확히 일치해야 함) +isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2], [1, 2, 3]); // false + +// 특수 타입 매칭 +isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +isMatch(/abc/gi, /abc/gi); // true + +// 빈 source는 항상 true +isMatch({ a: 1, b: 2 }, {}); // true +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/isMatch.ts) diff --git a/website/docs/ko/api/matches.md b/website/docs/ko/api/matches.md new file mode 100644 index 000000000..79a75ae03 --- /dev/null +++ b/website/docs/ko/api/matches.md @@ -0,0 +1,65 @@ +--- +id: matches +--- + + + +## matches() function + +주어진 패턴의 모든 속성과 일치하는지 확인하는 술어(predicate) 함수를 생성합니다. + +중첩 객체와 배열에 대해 깊은 비교를 수행합니다. +`find`, `filter`, `some`, `every`에서 콜백 함수를 객체 패턴으로 대체할 때 유용합니다. + +**Signature:** + +```typescript +function matches(pattern: Record): (input: T) => boolean; +``` + +## Example + +```ts +const users = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: false }, + { name: "Bob", age: 30, active: true }, +]; + +// filter와 함께 사용 +filter(matches({ age: 30, active: true }), users); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// find와 함께 사용 +find(matches({ active: true }), users); +// { name: "John", age: 30, active: true } + +// pipe와 함께 사용 +pipe(users, filter(matches({ active: true })), toArray); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// 중첩 객체 매칭 +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 } } }] + +// 배열 값 매칭 +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"] }] + +// null/undefined 입력에 대해 false 반환 +const matcher = matches({ a: 1 }); +matcher(null); // false +matcher(undefined); // false +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) diff --git a/website/docs/zh/api/index.md b/website/docs/zh/api/index.md index cebf7842f..f0fcde3b9 100644 --- a/website/docs/zh/api/index.md +++ b/website/docs/zh/api/index.md @@ -81,6 +81,7 @@ id: index - [isBoolean](/zh/api/isBoolean) - [isDate](/zh/api/isDate) - [isEmpty](/zh/api/isEmpty) +- [isMatch](/zh/api/isMatch) - [isNil](/zh/api/isNil) - [isNumber](/zh/api/isNumber) - [isObject](/zh/api/isObject) @@ -91,6 +92,7 @@ id: index - [last](/zh/api/last) - [lt](/zh/api/lt) - [lte](/zh/api/lte) +- [matches](/zh/api/matches) - [max](/zh/api/max) - [memoize](/zh/api/memoize) - [min](/zh/api/min) diff --git a/website/docs/zh/api/isMatch.md b/website/docs/zh/api/isMatch.md new file mode 100644 index 000000000..a8aafdba2 --- /dev/null +++ b/website/docs/zh/api/isMatch.md @@ -0,0 +1,43 @@ +--- +id: isMatch +--- + +## isMatch() function + +在 `object` 和 `source` 之间执行部分深度比较(partial deep comparison),确定 `object` 是否包含 `source` 的所有属性值。 + +用作 `matches` 函数的内部比较逻辑。 +`source` 只需要匹配 `object` 属性的子集即可。 + +支持的类型:原始类型、Object(包括嵌套)、Array、Date、RegExp、Map、Set。 + +**Signature:** + +```typescript +function isMatch(object: unknown, source: unknown): boolean; +``` + +## Example + +```ts +// 部分对象匹配 +isMatch({ a: 1, b: 2 }, { a: 1 }); // true +isMatch({ a: 1 }, { a: 1, b: 2 }); // false - object 缺少 'b' + +// 嵌套对象匹配 +isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true +isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false + +// 数组匹配(必须完全匹配) +isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2], [1, 2, 3]); // false + +// 特殊类型匹配 +isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +isMatch(/abc/gi, /abc/gi); // true + +// 空 source 始终返回 true +isMatch({ a: 1, b: 2 }, {}); // true +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/isMatch.ts) diff --git a/website/docs/zh/api/matches.md b/website/docs/zh/api/matches.md new file mode 100644 index 000000000..41e8e10fd --- /dev/null +++ b/website/docs/zh/api/matches.md @@ -0,0 +1,63 @@ +--- +id: matches +--- + +## matches() function + +创建一个谓词(predicate)函数,用于检查输入是否匹配给定模式的所有属性。 + +对嵌套对象和数组执行深度比较。 +在 `find`、`filter`、`some`、`every` 中用对象模式替换回调函数时非常有用。 + +**Signature:** + +```typescript +function matches(pattern: Record): (input: T) => boolean; +``` + +## Example + +```ts +const users = [ + { name: "John", age: 30, active: true }, + { name: "Jane", age: 25, active: false }, + { name: "Bob", age: 30, active: true }, +]; + +// 与 filter 一起使用 +filter(matches({ age: 30, active: true }), users); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// 与 find 一起使用 +find(matches({ active: true }), users); +// { name: "John", age: 30, active: true } + +// 与 pipe 一起使用 +pipe(users, filter(matches({ active: true })), toArray); +// [{ name: "John", age: 30, active: true }, { name: "Bob", age: 30, active: true }] + +// 嵌套对象匹配 +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 } } }] + +// 数组值匹配 +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"] }] + +// 对于 null/undefined 输入返回 false +const matcher = matches({ a: 1 }); +matcher(null); // false +matcher(undefined); // false +``` + +[Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) diff --git a/website/generate-api-docs/function.json b/website/generate-api-docs/function.json index 57b55ba6c..bcc803668 100644 --- a/website/generate-api-docs/function.json +++ b/website/generate-api-docs/function.json @@ -76,6 +76,7 @@ "isBoolean", "isDate", "isEmpty", + "isMatch", "isNil", "isNumber", "isObject", @@ -86,6 +87,7 @@ "last", "lt", "lte", + "matches", "max", "memoize", "min", From 27fec8a4efa7d71768f5816d2258aa0e43e6e4cd Mon Sep 17 00:00:00 2001 From: boy672820 Date: Mon, 23 Feb 2026 13:14:11 +0900 Subject: [PATCH 4/8] fix: correct isMatch for NaN, types, Set/Array - SameValueZero for primitives - Type guards for Date/RegExp/Map/Set - DFS for Set matching - Array partial match --- src/isMatch.ts | 75 ++++++++++++---- test/isMatch.spec.ts | 177 +++++++++++++++++++++++++++++++------ type-check/isMatch.test.ts | 14 +-- 3 files changed, 216 insertions(+), 50 deletions(-) diff --git a/src/isMatch.ts b/src/isMatch.ts index bbfb0a108..516fa36e7 100644 --- a/src/isMatch.ts +++ b/src/isMatch.ts @@ -21,10 +21,24 @@ import isNil from "./isNil"; * * // Array matching (must match exactly) * isMatch([1, 2, 3], [1, 2, 3]); // true + * isMatch([1, 2, 3], [1, 2]); // true * isMatch([1, 2], [1, 2, 3]); // false * - * // Special type matching - * isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true + * // 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 @@ -38,18 +52,23 @@ function isMatch(object: unknown, source: unknown): boolean { if (typeof source !== typeof object) return false; - if (typeof source !== "object") return source === object; + 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) { + 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) { + 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.size !== object.size) return false; + 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; @@ -58,23 +77,45 @@ function isMatch(object: unknown, source: unknown): boolean { 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 (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(objectValues.length).fill(-1); + + const findMatchingPath = ( + sourceIndex: number, + visited: Set, + ): 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; } } - if (!found) return false; + 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; + if (source.length > object.length) return false; for (let i = 0; i < source.length; i++) { if (!isMatch(object[i], source[i])) return false; } diff --git a/test/isMatch.spec.ts b/test/isMatch.spec.ts index 9ca5c5460..bb792a12e 100644 --- a/test/isMatch.spec.ts +++ b/test/isMatch.spec.ts @@ -12,6 +12,10 @@ describe("isMatch", function () { expect(isMatch(1, 2)).toBe(false); expect(isMatch("hello", "world")).toBe(false); }); + + it("should treat NaN as equal to NaN", function () { + expect(isMatch(NaN, NaN)).toBe(true); + }); }); describe("partial object matching", function () { @@ -28,72 +32,191 @@ describe("isMatch", 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 treat NaN values in properties as equal", function () { + expect(isMatch({ a: NaN }, { a: NaN })).toBe(true); + expect(isMatch({ a: NaN, b: 1 }, { a: NaN })).toBe(true); }); - it("should return false for non-matching nested objects", function () { + it("should match nested objects partially", function () { const object = { user: { name: "John", age: 30 } }; - expect(isMatch(object, { user: { name: "Jane" } })).toBe(false); + expect(isMatch(object, { user: { name: "John" } })).toBe(true); }); it("should match deeply nested objects", function () { - const object = { - level1: { - level2: { - level3: { value: "deep" }, - }, - }, - }; - expect( - isMatch(object, { level1: { level2: { level3: { value: "deep" } } } }), - ).toBe(true); + const object = { a: { b: { c: true } } }; + expect(isMatch(object, { a: { b: { c: true } } })).toBe(true); + expect(isMatch(object, { a: { b: { c: false } } })).toBe(false); }); }); describe("array matching", function () { it("should return true for equal arrays", function () { expect(isMatch([1, 2, 3], [1, 2, 3])).toBe(true); + expect(isMatch([1, 2, 3], [1, 2, 4])).toBe(false); }); - it("should return false for different arrays", function () { - expect(isMatch([1, 2, 3], [1, 2, 4])).toBe(false); + it("should partially match when source is a prefix of object", function () { + expect(isMatch([1, 2, 3], [1, 2])).toBe(true); + expect(isMatch([1, 2, 3], [1])).toBe(true); + expect(isMatch([1, 2, 3], [])).toBe(true); + }); + + it("should return false when source is longer than object", function () { 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 () { + it("should return false when prefix values differ", function () { + expect(isMatch([1, 2, 3], [2, 3])).toBe(false); + }); + + it("should match arrays with objects partially", function () { expect(isMatch([{ a: 1 }], [{ a: 1 }])).toBe(true); + expect(isMatch([{ a: 1, b: 2 }, { c: 3 }], [{ a: 1 }])).toBe(true); }); }); - describe("special types", function () { - it("should match Date objects", function () { + describe("Date matching", function () { + it("should match equal Date objects", function () { expect(isMatch(new Date("2024-01-01"), new Date("2024-01-01"))).toBe( true, ); + }); + + it("should return false for different Date objects", function () { expect(isMatch(new Date("2024-01-01"), new Date("2024-02-01"))).toBe( false, ); }); - it("should match RegExp objects", function () { + it("should return false when comparing Date with non-Date", function () { + expect(isMatch(new Date("2024-01-01"), {})).toBe(false); + expect(isMatch({}, new Date("2024-01-01"))).toBe(false); + }); + }); + + describe("RegExp matching", function () { + it("should match equal RegExp objects", function () { expect(isMatch(/abc/gi, /abc/gi)).toBe(true); + }); + + it("should return false for different RegExp objects", function () { 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 return false when comparing RegExp with non-RegExp", function () { + expect(isMatch(/abc/, {})).toBe(false); + expect(isMatch({}, /abc/)).toBe(false); + }); + }); + + describe("Map matching", function () { + it("should match equal Map objects", function () { + const object = new Map([["a", 1]]); + const source = new Map([["a", 1]]); + expect(isMatch(object, source)).toBe(true); + }); + + it("should partially match Map objects", function () { + const object = new Map([ + ["a", 1], + ["b", 2], + ]); + const source = new Map([["a", 1]]); + expect(isMatch(object, source)).toBe(true); + }); + + it("should return false for non-matching Map values", function () { + const object = new Map([ + ["a", 1], + ["b", 2], + ]); + const source = new Map([ + ["a", 1], + ["b", 3], + ]); + expect(isMatch(object, source)).toBe(false); }); - it("should match Set objects", function () { + it("should return false when source Map has keys not in object", function () { + const object = new Map([["a", 1]]); + const source = new Map([ + ["a", 1], + ["b", 2], + ]); + expect(isMatch(object, source)).toBe(false); + }); + + it("should return false when comparing Map with non-Map", function () { + expect(isMatch(new Map(), {})).toBe(false); + expect(isMatch({}, new Map())).toBe(false); + }); + + it("should handle NaN in Map values", function () { + const object = new Map([["a", NaN]]); + const source = new Map([["a", NaN]]); + expect(isMatch(object, source)).toBe(true); + }); + }); + + describe("Set matching", function () { + it("should partially match Set objects", function () { const set1 = new Set([1, 2, 3]); const set2 = new Set([1, 2, 3]); expect(isMatch(set1, set2)).toBe(true); }); + + it("should return false for non-matching Set objects", function () { + const set1 = new Set([1, 2, 3]); + const set2 = new Set([1, 4]); + expect(isMatch(set1, set2)).toBe(false); + }); + + it("should return false when source Set is larger than object", function () { + const object = new Set([1, 2]); + const source = new Set([1, 2, 3]); + expect(isMatch(object, source)).toBe(false); + }); + + it("should not allow the same object value to match multiple source values", function () { + const object = new Set([{ a: 1 }, { b: 2 }]); + const source = new Set([{ a: 1 }, { a: 1 }]); + expect(isMatch(object, source)).toBe(false); + }); + + it("should correctly match Sets with structurally identical but distinct values", function () { + const object = new Set([{ a: 1 }, { a: 1 }]); + const source = new Set([{ a: 1 }]); + expect(isMatch(object, source)).toBe(true); + }); + + it("should match Set values regardless of iteration order", function () { + const object = new Set([{ a: 1, b: 1 }, { a: 1 }]); + const source = new Set([{ a: 1 }, { a: 1, b: 1 }]); + expect(isMatch(object, source)).toBe(true); + }); + + it("should match Set with complex order-dependent scenarios", function () { + const object = new Set([{ x: 1, y: 2 }, { x: 1 }, { z: 3 }]); + const source = new Set([{ x: 1 }, { x: 1, y: 2 }]); + expect(isMatch(object, source)).toBe(true); + }); + + it("should return false when comparing Set with non-Set", function () { + expect(isMatch(new Set(), {})).toBe(false); + expect(isMatch({}, new Set())).toBe(false); + }); + + it("should handle NaN in Sets", function () { + expect(isMatch(new Set([NaN, 1]), new Set([NaN]))).toBe(true); + }); + }); + + describe("cross-type matching", function () { + it("should return false when comparing incompatible special types", function () { + expect(isMatch(new Date("2024-01-01"), new Map())).toBe(false); + expect(isMatch(new Map(), new Set())).toBe(false); + expect(isMatch(new Set([1]), /abc/)).toBe(false); + }); }); describe("null and undefined", function () { diff --git a/type-check/isMatch.test.ts b/type-check/isMatch.test.ts index 6d1b79c77..7cd344d8a 100644 --- a/type-check/isMatch.test.ts +++ b/type-check/isMatch.test.ts @@ -5,17 +5,17 @@ const { checks, check } = Test; const res1 = isMatch({ a: 1, b: 2 }, { a: 1 }); const res2 = isMatch({ a: 1 }, { a: 1, b: 2 }); -const res3 = isMatch({ user: { name: "John" } }, { user: { name: "John" } }); +const res3 = isMatch({ a: { b: 1 } }, { a: { b: 1 } }); const res4 = isMatch([1, 2, 3], [1, 2, 3]); -const res5 = isMatch(new Date("2024-01-01"), new Date("2024-01-01")); +const res5 = isMatch(new Date(), new Date()); const res6 = isMatch(/abc/gi, /abc/gi); const res7 = isMatch({ a: 1 }, {}); const res8 = isMatch(null, { a: 1 }); const res9 = isMatch({ a: 1 }, null); - -const res10 = isMatch(new Map([["a", 1]]), new Map([["a", 1]])); - -const res11 = isMatch(new Set([1, 2, 3]), new Set([1, 2, 3])); +const res10 = isMatch(undefined, { a: 1 }); +const res11 = isMatch({ a: 1 }, undefined); +const res12 = isMatch(new Map([["a", 1]]), new Map([["a", 1]])); +const res13 = isMatch(new Set([1, 2, 3]), new Set([1, 2, 3])); checks([ check(), @@ -29,4 +29,6 @@ checks([ check(), check(), check(), + check(), + check(), ]); From 104bcae800915be5e2a500ae512d265fc34f9ee9 Mon Sep 17 00:00:00 2001 From: boy672820 Date: Mon, 23 Feb 2026 13:48:03 +0900 Subject: [PATCH 5/8] fix: enhance pattern type safety with DeepPartial, DeepWiden, RejectSymbolKeys - Use DeepPartial & RejectSymbolKeys for partial matching and Symbol rejection - Add DeepWiden to widen literals to base types --- src/matches.ts | 70 +++++++++++++++++++++++++++---- test/matches.spec.ts | 31 ++++---------- type-check/matches.test.ts | 86 ++++++++++++++++---------------------- 3 files changed, 105 insertions(+), 82 deletions(-) diff --git a/src/matches.ts b/src/matches.ts index 5c616897e..becc9ae2e 100644 --- a/src/matches.ts +++ b/src/matches.ts @@ -2,7 +2,43 @@ import every from "./every"; import isMatch from "./isMatch"; import isNil from "./isNil"; import { entries } from "./Lazy"; -import type Key from "./types/Key"; + +type DeepPartial = T extends Date | RegExp | Map | Set + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; +type Widen = T extends boolean + ? boolean + : T extends number + ? number + : T extends string + ? string + : T; +type DeepWiden = T extends Date | RegExp | Map | Set + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { [K in keyof T]: DeepWiden } + : Widen; +type RejectSymbolKeys = T extends Date | RegExp | Map | Set + ? T + : T extends object + ? { + [K in keyof T]: K extends symbol + ? never + : T[K] extends object + ? RejectSymbolKeys + : T[K]; + } + : T; /** * Creates a predicate function that checks if an input matches all properties in the given pattern. @@ -51,19 +87,37 @@ import type Key from "./types/Key"; * // 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(pattern: Record): (input: T) => boolean; +function matches( + pattern: DeepPartial & RejectSymbolKeys, +): (input: DeepWiden) => boolean; -function matches(pattern: Record): (input: T) => boolean { - return (input: T): boolean => +function matches( + pattern: Record, +): (input: any) => boolean { + return (input) => isNil(input) ? false - : every( - ([key, value]) => isMatch(input[key as keyof T], value), - entries(pattern), - ); + : every(([key, value]) => isMatch(input[key], value), entries(pattern)); } export default matches; diff --git a/test/matches.spec.ts b/test/matches.spec.ts index d060a34e8..a47a25593 100644 --- a/test/matches.spec.ts +++ b/test/matches.spec.ts @@ -4,38 +4,25 @@ describe("matches", function () { describe("basic matching", function () { it("should return true when all properties match", function () { const matcher = matches({ a: 1, b: "2", c: true }); - - expect(matcher({ a: 1, b: "2", c: true, d: null })).toBe(true); + const input = { a: 1, b: "2", c: true, d: {} }; + expect(matcher(input)).toBe(true); }); it("should return false when any property does not match", function () { const matcher = matches({ a: 1, b: "2", c: true }); - - expect(matcher({ a: 1, b: "2", c: false })).toBe(false); - }); - - it("should return false when property is missing", function () { - const matcher = matches({ a: 1, b: "2" }); - - expect(matcher({ a: 1 })).toBe(false); + const input = { a: 1, b: "2", c: false }; + expect(matcher(input)).toBe(false); }); it("should return true for empty pattern", function () { const matcher = matches({}); - expect(matcher({ a: 1 })).toBe(true); }); - it("should return false for null input", function () { - const matcher = matches({ a: 1 }); - - expect(matcher(null)).toBe(false); - }); - - it("should return false for undefined input", function () { - const matcher = matches({ a: 1 }); - - expect(matcher(undefined)).toBe(false); + it("should return false for nil input", function () { + const matcher = matches({}); + expect(matcher(null as any)).toBe(false); + expect(matcher(undefined as any)).toBe(false); }); it("should be able to used as a curried function in the pipeline", function () { @@ -43,7 +30,6 @@ describe("matches", function () { { a: 1, b: "2", c: true, d: null }, matches({ a: 1, b: "2" }), ); - expect(result).toBe(true); }); }); @@ -215,7 +201,6 @@ describe("matches", function () { expect(matcher({ tags: ["a", "c"] })).toBe(false); expect(matcher({ tags: ["a"] })).toBe(false); - expect(matcher({ tags: ["a", "b", "c"] })).toBe(false); }); it("should match Date objects", function () { diff --git a/type-check/matches.test.ts b/type-check/matches.test.ts index d48f621be..6dff1241a 100644 --- a/type-check/matches.test.ts +++ b/type-check/matches.test.ts @@ -1,61 +1,45 @@ -import { filter, find, matches, pipe, toArray } from "../src"; +import { every, filter, find, matches, pipe, some } from "../src"; import * as Test from "../src/types/Test"; const { checks, check } = Test; -type User = { - name: string; - age: number; - active: boolean; -}; - -const users: User[] = [ - { name: "John", age: 30, active: true }, - { name: "Jane", age: 25, active: false }, - { name: "Bob", age: 30, active: true }, -]; - -const res1 = matches({ age: 30 }); -const res2 = matches({ age: 30, active: true }); -const res3 = matches({}); - -const res4 = res1({ name: "John", age: 30, active: true }); -const res5 = res1(null); -const res6 = res1(undefined); - -const res7 = filter(matches({ age: 30 }), users); -const res8 = find(matches({ active: true }), users); - -const res9 = pipe(users, filter(matches({ age: 30 })), toArray); -const res10 = pipe(users, find(matches({ name: "John" }))); - -type NestedUser = { - id: number; - profile: { - name: string; - age: number; - }; -}; - -const nestedUsers: NestedUser[] = [ - { id: 1, profile: { name: "John", age: 30 } }, - { id: 2, profile: { name: "Jane", age: 25 } }, -]; - -const res11 = filter(matches({ profile: { age: 30 } }), nestedUsers); -const res12 = find(matches({ profile: { name: "John" } }), nestedUsers); +const res1 = matches({ a: 1 }); +const res2 = matches({ a: 1 })({ a: 1 }); +const res3 = matches({ a: { b: { c: 1 } } })({ a: { b: { c: 1 } } }); +const res4 = matches(1)(1); +const res5 = matches("a")("a"); +const res6 = matches(true)(true); +const res7 = matches(null)(null); +const res8 = matches(undefined)(undefined); +const res9 = matches([1, 2, 3])([1, 2, 3]); +const res10 = matches(new Date())(new Date()); +const res11 = matches(/abc/gi)(/abc/gi); + +const input = { a: 1, b: 2 }; +const res12 = matches({ a: 1 })(input); + +const res13 = pipe({ a: 1 }, matches({ a: 1 })); +const res14 = find(matches({ a: 1 }), [{ a: 1 }, { a: 2 }]); +const res15 = filter(matches({ a: 1 }), [{ a: 1 }, { a: 2 }]); +const res16 = some(matches({ a: 1 }), [{ a: 1 }, { a: 2 }]); +const res17 = every(matches({ a: 1 }), [{ a: 1 }, { a: 1 }]); checks([ - check boolean, Test.Pass>(), - check boolean, Test.Pass>(), - check boolean, Test.Pass>(), + check boolean, Test.Pass>(), + check(), + check(), check(), check(), check(), - check, Test.Pass>(), - check(), - check(), - check(), - check, Test.Pass>(), - check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check(), + check, Test.Pass>(), + check(), + check(), ]); From 274acf49eacd098ed07a411fada00a0a141ec373 Mon Sep 17 00:00:00 2001 From: boy672820 Date: Mon, 23 Feb 2026 13:51:04 +0900 Subject: [PATCH 6/8] docs: add Symbol key handling details and new examples to matches and isMatch docs --- website/docs/ja/api/isMatch.md | 4 +++- website/docs/ja/api/matches.md | 15 +++++++++++++++ website/docs/ko/api/isMatch.md | 23 ++++++++++++++++++++--- website/docs/ko/api/matches.md | 22 +++++++++++++++++++--- website/docs/zh/api/isMatch.md | 4 +++- website/docs/zh/api/matches.md | 15 +++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/website/docs/ja/api/isMatch.md b/website/docs/ja/api/isMatch.md index 2f9545cad..648688fcb 100644 --- a/website/docs/ja/api/isMatch.md +++ b/website/docs/ja/api/isMatch.md @@ -32,10 +32,12 @@ isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false // 配列マッチング(完全に一致する必要がある) isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2, 3], [1, 2]); // true isMatch([1, 2], [1, 2, 3]); // false // 特殊型のマッチング -isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +const now = Date.now(); +isMatch(new Date(now), new Date(now)); // true isMatch(/abc/gi, /abc/gi); // true // 空のsourceは常にtrue diff --git a/website/docs/ja/api/matches.md b/website/docs/ja/api/matches.md index f9bd34669..b54e97c0b 100644 --- a/website/docs/ja/api/matches.md +++ b/website/docs/ja/api/matches.md @@ -62,4 +62,19 @@ matcher(null); // false matcher(undefined); // false ``` +パターンの Symbol キーはランタイムで無視されます。 +内部的に `matches` は `Object.entries` を使用してパターンのエントリを反復するため、 +文字列キー(および数値キー)のプロパティのみが列挙されます。 + +```ts +const sym = Symbol("id"); +// matches({ [sym]: 123 }); +// ~~~~ +// Error: Type 'symbol' cannot be used as an index type. + +// ランタイムでバイパスした場合、Symbolキーは無視されます: +const pattern = { [sym]: 123 } as any; +matches(pattern)({}); // true — Symbolキーは比較されなかった +``` + [Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) diff --git a/website/docs/ko/api/isMatch.md b/website/docs/ko/api/isMatch.md index 6f6db30b6..bdcb47373 100644 --- a/website/docs/ko/api/isMatch.md +++ b/website/docs/ko/api/isMatch.md @@ -30,12 +30,29 @@ isMatch({ a: 1 }, { a: 1, b: 2 }); // false - object에 'b'가 없음 isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false -// 배열 매칭 (정확히 일치해야 함) +// 배열 매칭 isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2, 3], [1, 2]); // true isMatch([1, 2], [1, 2, 3]); // false -// 특수 타입 매칭 -isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +// Map 매칭 +const map1 = new Map([ + ["key1", "value1"], + ["key2", "value2"], +]); +const map2 = new Map([["key1", "value1"]]); +isMatch(map1, map2); // true + +// Set 매칭 +const set1 = new Set([1, 2, 3]); +const set2 = new Set([1, 2]); +isMatch(set1, set2); // true + +// Date 매칭 +const now = Date.now(); +isMatch(new Date(now), new Date(now)); // true + +// RegExp 매칭 isMatch(/abc/gi, /abc/gi); // true // 빈 source는 항상 true diff --git a/website/docs/ko/api/matches.md b/website/docs/ko/api/matches.md index 79a75ae03..94e266836 100644 --- a/website/docs/ko/api/matches.md +++ b/website/docs/ko/api/matches.md @@ -19,7 +19,7 @@ function matches(pattern: Record): (input: T) => boolean; ## Example -```ts +````ts const users = [ { name: "John", age: 30, active: true }, { name: "Jane", age: 25, active: false }, @@ -58,8 +58,24 @@ filter(matches({ tags: ["a", "b"] }), items); // null/undefined 입력에 대해 false 반환 const matcher = matches({ a: 1 }); -matcher(null); // false +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 키는 런타임에서 무시됩니다. (문자열 키(및 숫자 키) 속성만 열거됩니다.) + +```ts +const sym = Symbol("id"); +// matches({ [sym]: 123 }); +// ~~~~ +// Error: Type 'symbol' cannot be used as an index type. + +// 런타임에서 우회할 경우, Symbol 키는 무시됩니다: +const pattern = { [sym]: 123 } as any; +matches(pattern)({}); // true — Symbol 키는 비교되지 않음 +```` [Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) diff --git a/website/docs/zh/api/isMatch.md b/website/docs/zh/api/isMatch.md index a8aafdba2..6c443d8c3 100644 --- a/website/docs/zh/api/isMatch.md +++ b/website/docs/zh/api/isMatch.md @@ -30,10 +30,12 @@ isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false // 数组匹配(必须完全匹配) isMatch([1, 2, 3], [1, 2, 3]); // true +isMatch([1, 2, 3], [1, 2]); // true isMatch([1, 2], [1, 2, 3]); // false // 特殊类型匹配 -isMatch(new Date("2024-01-01"), new Date("2024-01-01")); // true +const now = Date.now(); +isMatch(new Date(now), new Date(now)); // true isMatch(/abc/gi, /abc/gi); // true // 空 source 始终返回 true diff --git a/website/docs/zh/api/matches.md b/website/docs/zh/api/matches.md index 41e8e10fd..6ac791ae3 100644 --- a/website/docs/zh/api/matches.md +++ b/website/docs/zh/api/matches.md @@ -60,4 +60,19 @@ matcher(null); // false matcher(undefined); // false ``` +模式中的 Symbol 键在运行时会被静默忽略。 +内部实现中,`matches` 通过 `Object.entries` 遍历模式条目, +因此只会枚举字符串键(和数字键)属性。 + +```ts +const sym = Symbol("id"); +// matches({ [sym]: 123 }); +// ~~~~ +// Error: Type 'symbol' cannot be used as an index type. + +// 如果在运行时绕过类型检查,Symbol 键将被忽略: +const pattern = { [sym]: 123 } as any; +matches(pattern)({}); // true — Symbol 键未被比较 +``` + [Open Source Code](https://github.com/marpple/FxTS/blob/main/src/matches.ts) From 152b8c9a60264fcb6626575fa22dedec1dab60c8 Mon Sep 17 00:00:00 2001 From: boy672820 Date: Sun, 31 May 2026 18:56:58 +0900 Subject: [PATCH 7/8] docs: align isMatch array matching descriptions --- src/isMatch.ts | 2 +- test/isMatch.spec.ts | 4 ++-- website/docs/ja/api/isMatch.md | 2 +- website/docs/zh/api/isMatch.md | 4 +++- website/docs/zh/api/matches.md | 2 ++ 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/isMatch.ts b/src/isMatch.ts index 516fa36e7..21bb88a58 100644 --- a/src/isMatch.ts +++ b/src/isMatch.ts @@ -19,7 +19,7 @@ import isNil from "./isNil"; * isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true * isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false * - * // Array matching (must match exactly) + * // 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 diff --git a/test/isMatch.spec.ts b/test/isMatch.spec.ts index bb792a12e..026f9e6fe 100644 --- a/test/isMatch.spec.ts +++ b/test/isMatch.spec.ts @@ -50,7 +50,7 @@ describe("isMatch", function () { }); describe("array matching", function () { - it("should return true for equal arrays", function () { + it("should compare arrays by index for matching positions", function () { expect(isMatch([1, 2, 3], [1, 2, 3])).toBe(true); expect(isMatch([1, 2, 3], [1, 2, 4])).toBe(false); }); @@ -159,7 +159,7 @@ describe("isMatch", function () { }); describe("Set matching", function () { - it("should partially match Set objects", function () { + it("should return true for equal Set objects", function () { const set1 = new Set([1, 2, 3]); const set2 = new Set([1, 2, 3]); expect(isMatch(set1, set2)).toBe(true); diff --git a/website/docs/ja/api/isMatch.md b/website/docs/ja/api/isMatch.md index 648688fcb..be6a6de7f 100644 --- a/website/docs/ja/api/isMatch.md +++ b/website/docs/ja/api/isMatch.md @@ -30,7 +30,7 @@ isMatch({ a: 1 }, { a: 1, b: 2 }); // false - objectに'b'がない isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false -// 配列マッチング(完全に一致する必要がある) +// 配列マッチング(部分一致, prefixベース) isMatch([1, 2, 3], [1, 2, 3]); // true isMatch([1, 2, 3], [1, 2]); // true isMatch([1, 2], [1, 2, 3]); // false diff --git a/website/docs/zh/api/isMatch.md b/website/docs/zh/api/isMatch.md index 6c443d8c3..8ba32cbad 100644 --- a/website/docs/zh/api/isMatch.md +++ b/website/docs/zh/api/isMatch.md @@ -2,6 +2,8 @@ id: isMatch --- + + ## isMatch() function 在 `object` 和 `source` 之间执行部分深度比较(partial deep comparison),确定 `object` 是否包含 `source` 的所有属性值。 @@ -28,7 +30,7 @@ isMatch({ a: 1 }, { a: 1, b: 2 }); // false - object 缺少 'b' isMatch({ user: { name: "John", age: 30 } }, { user: { name: "John" } }); // true isMatch({ user: { name: "John" } }, { user: { name: "Jane" } }); // false -// 数组匹配(必须完全匹配) +// 数组匹配(部分匹配, 基于前缀) isMatch([1, 2, 3], [1, 2, 3]); // true isMatch([1, 2, 3], [1, 2]); // true isMatch([1, 2], [1, 2, 3]); // false diff --git a/website/docs/zh/api/matches.md b/website/docs/zh/api/matches.md index 6ac791ae3..e30381fed 100644 --- a/website/docs/zh/api/matches.md +++ b/website/docs/zh/api/matches.md @@ -2,6 +2,8 @@ id: matches --- + + ## matches() function 创建一个谓词(predicate)函数,用于检查输入是否匹配给定模式的所有属性。 From 3956a4b812784c2840e68476eb63166341e6deab Mon Sep 17 00:00:00 2001 From: hw Date: Wed, 17 Jun 2026 11:36:57 +0900 Subject: [PATCH 8/8] fix: compare top-level non-object patterns in matches by value matches() iterated only the pattern's top-level entries, so a top-level Date/RegExp/Map/Set/Array/primitive pattern (which has no enumerable string keys) matched every input vacuously. Delegate those to isMatch so they are compared by value, while keeping a fast path for the common plain-object pattern that hoists Object.keys out of the predicate hot path (~2.5x faster than always delegating on a 100k-item filter). --- src/matches.ts | 37 +++++++++++++++++---- test/matches.spec.ts | 79 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/matches.ts b/src/matches.ts index becc9ae2e..38109e4ad 100644 --- a/src/matches.ts +++ b/src/matches.ts @@ -1,7 +1,5 @@ -import every from "./every"; import isMatch from "./isMatch"; import isNil from "./isNil"; -import { entries } from "./Lazy"; type DeepPartial = T extends Date | RegExp | Map | Set ? T @@ -40,6 +38,15 @@ type RejectSymbolKeys = T extends Date | RegExp | Map | Set } : 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. * @@ -114,10 +121,28 @@ function matches( function matches( pattern: Record, ): (input: any) => boolean { - return (input) => - isNil(input) - ? false - : every(([key, value]) => isMatch(input[key], value), entries(pattern)); + // 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; diff --git a/test/matches.spec.ts b/test/matches.spec.ts index a47a25593..8001581d3 100644 --- a/test/matches.spec.ts +++ b/test/matches.spec.ts @@ -1,4 +1,14 @@ -import { every, filter, find, fx, map, matches, pipe, some } from "../src"; +import { + every, + filter, + find, + fx, + isMatch, + map, + matches, + pipe, + some, +} from "../src"; describe("matches", function () { describe("basic matching", function () { @@ -215,4 +225,71 @@ describe("matches", function () { expect(result).toEqual({ id: 1, created: new Date("2024-01-01") }); }); }); + + describe("top-level non-object patterns", function () { + it("should compare top-level Date patterns by value, not ignore them", function () { + const matcher = matches(new Date("2024-01-01")); + + expect(matcher(new Date("2024-01-01"))).toBe(true); + expect(matcher(new Date("2025-01-01"))).toBe(false); + }); + + it("should compare top-level RegExp patterns by value", function () { + const matcher = matches(/abc/gi); + + expect(matcher(/abc/gi)).toBe(true); + expect(matcher(/abc/g)).toBe(false); + }); + + it("should compare top-level primitive patterns by value", function () { + expect(matches(1)(1)).toBe(true); + expect(matches(1)(2)).toBe(false); + expect(matches("a")("a")).toBe(true); + expect(matches("a")("b")).toBe(false); + }); + + it("should compare top-level array patterns by prefix", function () { + const matcher = matches([1, 2]); + + expect(matcher([1, 2, 3])).toBe(true); + expect(matcher([1, 3])).toBe(false); + }); + + it("should compare top-level Map patterns by value", function () { + const matcher = matches(new Map([["a", 1]])); + + expect( + matcher( + new Map([ + ["a", 1], + ["b", 2], + ]), + ), + ).toBe(true); + expect(matcher(new Map([["a", 2]]))).toBe(false); + }); + + it("should compare top-level Set patterns by value", function () { + const matcher = matches(new Set([1, 2])); + + expect(matcher(new Set([1, 2, 3]))).toBe(true); + expect(matcher(new Set([1, 3]))).toBe(false); + }); + + it("should return false when nil pattern is matched against a non-nil input", function () { + expect(matches(null as any)({ a: 1 })).toBe(false); + expect(matches(undefined as any)({ a: 1 })).toBe(false); + }); + }); + + describe("consistency with isMatch", function () { + it("should produce the same result as isMatch(input, pattern)", function () { + const superset = { a: 1, b: 2 }; + expect(matches({ a: 1 })(superset)).toBe(isMatch(superset, { a: 1 })); + expect(matches({ a: 1 })({ a: 2 })).toBe(isMatch({ a: 2 }, { a: 1 })); + expect(matches(new Date("2024-01-01"))(new Date("2025-01-01"))).toBe( + isMatch(new Date("2025-01-01"), new Date("2024-01-01")), + ); + }); + }); });