From 59d6eee4265555b97e1a66a7a047dad0c4fd2e7d Mon Sep 17 00:00:00 2001 From: Minsang Kim Date: Sun, 7 Apr 2024 17:53:40 +0900 Subject: [PATCH 1/5] feat: added docs and test for P.object.exact(..) --- README.md | 17 +++++++++++++++++ src/types/Pattern.ts | 2 +- tests/object.test.ts | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e6b2b847..94f52d3f 100644 --- a/README.md +++ b/README.md @@ -1545,6 +1545,23 @@ console.log(isMatching(P.object.empty(), null)); // false console.log(isMatching(P.object.empty(), undefined)); // false ``` +### `P.object.exact({...})` + +`P.object.exact({...})` matches objects that contain exactly the set of defined in the pattern. + +```ts +import { match, P } from 'ts-pattern'; + +const fn = (input: unknown) => + match(input) + .with(P.object.exact({ a: P.any }), () => 'Objects with a single `a` key that contains anything.') + .otherwise(() => '❌'); + +console.log(fn({})); // ❌ +console.log(fn({ a: 1 })); // ✅ +console.log(fn({ a: 1, b: 2 })); // ❌ +``` + ## Types ### `P.infer` diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 6ca297c1..d6e295bd 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -695,7 +695,7 @@ export type ObjectChainable< * () => 'Objects with a single `a` key that contains anything.' * ) */ - >( + exact>( pattern: pattern ): Chainable, never>>; }, diff --git a/tests/object.test.ts b/tests/object.test.ts index 3c73efa7..38bdfbed 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -106,4 +106,22 @@ describe('Object', () => { expect(fn(null)).toEqual('no'); }); }); + + describe('P.object.exact({...})', () => { + it('should only catch the literal `{}`.', () => { + const fn = (input: object) => + match(input) + .with(P.object.exact({ a: P.any }), (obj) => { + type t = Expect>; + return 'yes'; + }) + // @ts-expect-error: non empty object aren't caught + .exhaustive(); + expect(fn({})).toEqual('yes'); + expect(() => fn({ hello: 'world' })).toThrow(); + expect(() => fn(() => {})).toThrow(); + expect(() => fn([1, 2, 3])).toThrow(); + expect(() => fn([])).toThrow(); + }); + }); }); From d3b514e5843f7e3c7645b14aa5d08e0e09b3a884 Mon Sep 17 00:00:00 2001 From: JUSTIVE Date: Sun, 7 Apr 2024 18:08:56 +0900 Subject: [PATCH 2/5] feat: added more tests --- tests/object.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/object.test.ts b/tests/object.test.ts index 38bdfbed..0a89bf71 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -108,20 +108,24 @@ describe('Object', () => { }); describe('P.object.exact({...})', () => { - it('should only catch the literal `{}`.', () => { + it('should only catch exact match.', () => { const fn = (input: object) => match(input) .with(P.object.exact({ a: P.any }), (obj) => { type t = Expect>; return 'yes'; }) - // @ts-expect-error: non empty object aren't caught - .exhaustive(); - expect(fn({})).toEqual('yes'); - expect(() => fn({ hello: 'world' })).toThrow(); - expect(() => fn(() => {})).toThrow(); - expect(() => fn([1, 2, 3])).toThrow(); - expect(() => fn([])).toThrow(); + .otherwise(() => 'no'); + + expect(fn({a: []})).toEqual('yes'); + expect(fn({a: null})).toEqual('yes'); + expect(fn({a: undefined})).toEqual('yes'); + expect(fn({a: undefined,b:undefined})).toEqual('no'); + expect(fn({})).toEqual('no'); + expect(() => fn({ hello: 'world' })).toEqual('no'); + expect(() => fn(() => {})).toEqual('no'); + expect(() => fn([1, 2, 3])).toEqual('no'); + expect(() => fn([])).toEqual('no'); }); }); }); From fc3d8b9b63fc30847de8fa7f05ab2ed524884ce4 Mon Sep 17 00:00:00 2001 From: JUSTIVE Date: Sun, 7 Apr 2024 19:50:20 +0900 Subject: [PATCH 3/5] fix: matches P.object.exact for non-nested scenarios, added nested match test --- src/patterns.ts | 5 ++++- src/types/Pattern.ts | 6 +++++- tests/object.test.ts | 33 ++++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/patterns.ts b/src/patterns.ts index 4d6f3245..ec08852c 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -656,10 +656,13 @@ function isObject(x: unknown): x is object { return !!x && (typeof x === 'object' || typeof x === 'function'); } -function hasExactKeys(keys: Set, x: unknown) { +function hasExactKeys(keys: Set, x: unknown) { if (!x || typeof x !== 'object') return false; + const xKeys = new Set(Object.keys(x)); if (Array.isArray(x)) return false; + if(xKeys.size !== keys.size) return false; for (const key in x) if (!keys.has(key)) return false; + for (const key in keys) if (!xKeys.has(key)) return false; return true; } diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index d6e295bd..c06e57cf 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -171,6 +171,10 @@ export type ObjectLiteralPattern = } | never; +export type ObjectExactPattern = { + readonly [k in keyof a]: Pattern; +}; + type ArrayPattern = a extends readonly (infer i)[] ? a extends readonly [any, ...any] ? { readonly [index in keyof a]: Pattern } @@ -695,7 +699,7 @@ export type ObjectChainable< * () => 'Objects with a single `a` key that contains anything.' * ) */ - exact>( + exact>( pattern: pattern ): Chainable, never>>; }, diff --git a/tests/object.test.ts b/tests/object.test.ts index 0a89bf71..49ddd2a5 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -116,16 +116,39 @@ describe('Object', () => { return 'yes'; }) .otherwise(() => 'no'); - + expect(fn({a: []})).toEqual('yes'); expect(fn({a: null})).toEqual('yes'); expect(fn({a: undefined})).toEqual('yes'); + expect(fn({a: 5})).toEqual('yes'); expect(fn({a: undefined,b:undefined})).toEqual('no'); expect(fn({})).toEqual('no'); - expect(() => fn({ hello: 'world' })).toEqual('no'); - expect(() => fn(() => {})).toEqual('no'); - expect(() => fn([1, 2, 3])).toEqual('no'); - expect(() => fn([])).toEqual('no'); + expect(fn({ hello: 'world' })).toEqual('no'); + expect(fn(() => {})).toEqual('no'); + expect(fn([1, 2, 3])).toEqual('no'); + expect(fn([])).toEqual('no'); + + const fn2 = (input: object) => + match(input) + .with(P.object.exact({ a: {b:P.any} }), (obj) => { + type t = Expect>; + return 'yes'; + }) + .otherwise(() => 'no'); + + expect(fn2({a: {b:[]}})).toEqual('yes'); + expect(fn2({a: null})).toEqual('no'); + expect(fn2({a: {b:null}})).toEqual('yes'); + expect(fn2({a: undefined})).toEqual('no'); + expect(fn2({a: {b:undefined}})).toEqual('yes'); + expect(fn2({a: 5})).toEqual('no'); + expect(fn2({a: {b:5}})).toEqual('yes'); + expect(fn2({a: undefined,b:undefined})).toEqual('no'); + expect(fn2({})).toEqual('no'); + expect(fn2({ hello: 'world' })).toEqual('no'); + expect(fn2(() => {})).toEqual('no'); + expect(fn2([1, 2, 3])).toEqual('no'); + expect(fn2([])).toEqual('no'); }); }); }); From 923d89604f2bf66ad964c46b6d070591f0fb4794 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 8 Apr 2024 01:30:16 +0900 Subject: [PATCH 4/5] Update README.md Co-authored-by: Gabriel Vergnaud <9265418+gvergnaud@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 94f52d3f..c56b36cb 100644 --- a/README.md +++ b/README.md @@ -1547,7 +1547,7 @@ console.log(isMatching(P.object.empty(), undefined)); // false ### `P.object.exact({...})` -`P.object.exact({...})` matches objects that contain exactly the set of defined in the pattern. +`P.object.exact({...})` matches objects that contain exactly the set of properties defined in the pattern. ```ts import { match, P } from 'ts-pattern'; From 4c124db79af5e2270b2da1627a6b264d68e0a4b9 Mon Sep 17 00:00:00 2001 From: JUSTIVE Date: Mon, 8 Apr 2024 01:31:56 +0900 Subject: [PATCH 5/5] chore: applied prettier --- tests/object.test.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/object.test.ts b/tests/object.test.ts index 49ddd2a5..2586274d 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -106,7 +106,7 @@ describe('Object', () => { expect(fn(null)).toEqual('no'); }); }); - + describe('P.object.exact({...})', () => { it('should only catch exact match.', () => { const fn = (input: object) => @@ -116,12 +116,12 @@ describe('Object', () => { return 'yes'; }) .otherwise(() => 'no'); - - expect(fn({a: []})).toEqual('yes'); - expect(fn({a: null})).toEqual('yes'); - expect(fn({a: undefined})).toEqual('yes'); - expect(fn({a: 5})).toEqual('yes'); - expect(fn({a: undefined,b:undefined})).toEqual('no'); + + expect(fn({ a: [] })).toEqual('yes'); + expect(fn({ a: null })).toEqual('yes'); + expect(fn({ a: undefined })).toEqual('yes'); + expect(fn({ a: 5 })).toEqual('yes'); + expect(fn({ a: undefined, b: undefined })).toEqual('no'); expect(fn({})).toEqual('no'); expect(fn({ hello: 'world' })).toEqual('no'); expect(fn(() => {})).toEqual('no'); @@ -130,20 +130,20 @@ describe('Object', () => { const fn2 = (input: object) => match(input) - .with(P.object.exact({ a: {b:P.any} }), (obj) => { - type t = Expect>; + .with(P.object.exact({ a: { b: P.any } }), (obj) => { + type t = Expect>; return 'yes'; }) .otherwise(() => 'no'); - expect(fn2({a: {b:[]}})).toEqual('yes'); - expect(fn2({a: null})).toEqual('no'); - expect(fn2({a: {b:null}})).toEqual('yes'); - expect(fn2({a: undefined})).toEqual('no'); - expect(fn2({a: {b:undefined}})).toEqual('yes'); - expect(fn2({a: 5})).toEqual('no'); - expect(fn2({a: {b:5}})).toEqual('yes'); - expect(fn2({a: undefined,b:undefined})).toEqual('no'); + expect(fn2({ a: { b: [] } })).toEqual('yes'); + expect(fn2({ a: null })).toEqual('no'); + expect(fn2({ a: { b: null } })).toEqual('yes'); + expect(fn2({ a: undefined })).toEqual('no'); + expect(fn2({ a: { b: undefined } })).toEqual('yes'); + expect(fn2({ a: 5 })).toEqual('no'); + expect(fn2({ a: { b: 5 } })).toEqual('yes'); + expect(fn2({ a: undefined, b: undefined })).toEqual('no'); expect(fn2({})).toEqual('no'); expect(fn2({ hello: 'world' })).toEqual('no'); expect(fn2(() => {})).toEqual('no');