diff --git a/README.md b/README.md index e6b2b847..c56b36cb 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 properties 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/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 6ca297c1..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>( pattern: pattern ): Chainable, never>>; }, diff --git a/tests/object.test.ts b/tests/object.test.ts index 3c73efa7..2586274d 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -106,4 +106,49 @@ describe('Object', () => { expect(fn(null)).toEqual('no'); }); }); + + describe('P.object.exact({...})', () => { + 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'; + }) + .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'); + + 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'); + }); + }); });