From e12e85cbe216f4e74dc31133608085df6e9add65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EB=AF=BC?= Date: Sun, 24 Mar 2024 00:01:03 +0900 Subject: [PATCH 1/6] docs: add P.object on README.md --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index c77bfbfa..58dae179 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ TS-Pattern assumes that [Strict Mode](https://www.typescriptlang.org/tsconfig#st - [`P.intersection` patterns](#pintersection-patterns) - [`P.string` predicates](#pstring-predicates) - [`P.number` and `P.bigint` predicates](#pnumber-and-pbigint-predicates) + - [`P.object` predicates](#pobject-predicates) - [Types](#types) - [`P.infer`](#pinfer) - [`P.Pattern`](#pPattern) @@ -1497,6 +1498,23 @@ const fn = (input: number) => console.log(fn(-3.141592), fn(7)); // logs '✅ ❌' ``` +## `P.object` predicates + +`P.object` has a number of methods to help you match on specific object. + +### `P.object.empty` + +`P.object.empty` matches empty object + +```ts +const fn = (input: string) => + match(input) + .with(P.object.empty(), () => 'Empty!') + .otherwise(() => 'Full!'); + +console.log(fn('{}')); // Empty +``` + ## Types ### `P.infer` From 380d60f53abb78737162edde712adb5d3a282e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EB=AF=BC?= Date: Sun, 24 Mar 2024 00:30:11 +0900 Subject: [PATCH 2/6] Add object, object.empty pattern and tests --- README.md | 2 +- src/patterns.ts | 39 +++++++++++++++++++++ src/types/Pattern.ts | 26 ++++++++++++++ tests/object.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/object.test.ts diff --git a/README.md b/README.md index 58dae179..47bb531a 100644 --- a/README.md +++ b/README.md @@ -1512,7 +1512,7 @@ const fn = (input: string) => .with(P.object.empty(), () => 'Empty!') .otherwise(() => 'Full!'); -console.log(fn('{}')); // Empty +console.log(fn({})); // Empty! ``` ## Types diff --git a/src/patterns.ts b/src/patterns.ts index c00e6842..9777c61c 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -35,6 +35,7 @@ import { ArrayChainable, Variadic, NonNullablePattern, + ObjectChainable, } from './types/Pattern'; export type { Pattern, Fn as unstable_Fn }; @@ -639,6 +640,10 @@ function isNonNullable(x: unknown): x is {} { return x !== null && x !== undefined; } +function isObject(x: T | object): x is object { + return typeof x === 'object' && !Array.isArray(x) && x !== null; +} + type AnyConstructor = abstract new (...args: any[]) => any; function isInstanceOf(classConstructor: T) { @@ -1125,3 +1130,37 @@ export function shape>( export function shape(pattern: UnknownPattern) { return chainable(when(isMatching(pattern))); } + +/** + * `P.object.empty()` is a pattern, matching **objects** with no keys. + * + * [Read the documentation for `P.object.empty` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) + * + * @example + * match(value) + * .with(P.object.empty(), () => 'will match on empty objects') + */ +const emptyObject = (): GuardExcludeP => + when( + (value) => + value && typeof value === 'object' && Object.keys(value).length === 0 + ); + +const objectChainable = >( + pattern: pattern +): ObjectChainable => + Object.assign(chainable(pattern), { + empty: () => objectChainable(intersection(pattern, emptyObject())), + }) as any; + +/** + * `P.object` is a wildcard pattern, matching any **object**. + * It lets you call methods like `.empty()`, `.and`, `.or` and `.select()` + * On structural patterns, like objects and arrays. + * [Read the documentation for `P.object` on GitHub](https://github.com/gvergnaud/ts-pattern#pobject-predicates) + * + * @example + * match(value) + * .with(P.object.empty(), () => 'will match on empty objects') + **/ +export const object: ObjectChainable = objectChainable(when(isObject)); diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 2d2e610e..010a23a0 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -10,6 +10,7 @@ export type MatcherType = | 'or' | 'and' | 'array' + | 'object' | 'map' | 'set' | 'select' @@ -92,6 +93,8 @@ export type CustomP = Matcher< export type ArrayP = Matcher; +export type ObjectP = Matcher; + export type OptionalP = Matcher; export type MapP = Matcher; @@ -655,3 +658,26 @@ export type ArrayChainable< }, omitted >; + +export type ObjectChainable< + pattern, + omitted extends string = never +> = Chainable & + Omit< + { + /** + * `.empty()` matches an empty object. + * + * [Read the documentation for `P.object.empty` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) + * + * @example + * match(value) + * .with(P.object.empty(), () => 'empty object') + */ + empty(): ObjectChainable< + ObjectP>, + omitted | 'empty' + >; + }, + omitted + >; diff --git a/tests/object.test.ts b/tests/object.test.ts new file mode 100644 index 00000000..a2a45b75 --- /dev/null +++ b/tests/object.test.ts @@ -0,0 +1,80 @@ +import { Expect, Equal } from '../src/types/helpers'; +import { P, match } from '../src'; + +describe('Object', () => { + it('should match exact object', () => { + const fn = () => 'hello'; + + const res = match({ str: fn() }) + .with({ str: 'world' }, (obj) => { + type t = Expect>; + return obj.str; + }) + .with(P.object, (obj) => { + type t = Expect>; + return 'not found'; + }) + .exhaustive(); + expect(res).toEqual('not found'); + }); + + it('should match object with nested objects', () => { + const res = match({ x: { y: 1 } }) + .with({ x: { y: 1 } }, (obj) => { + type t = Expect>; + return 'yes'; + }) + .with(P.object, (obj) => { + type t = Expect>; + return 'no'; + }) + .exhaustive(); + expect(res).toEqual('yes'); + }); + + it('should match object with nested objects and arrays', () => { + const res = match({ x: { y: [1] } }) + .with({ x: { y: [1] } }, (obj) => { + type t = Expect>; + return 'yes'; + }) + .with(P.object, (obj) => { + type t = Expect>; + return 'no'; + }) + .exhaustive(); + expect(res).toEqual('yes'); + }); + + it('should match empty object', () => { + const res = match({}) + .with(P.object.empty(), (obj) => { + type t = Expect>; + + return 'yes'; + }) + .with(P.object, (obj) => { + type t = Expect>; + + return 'no'; + }) + .exhaustive(); + expect(res).toEqual('yes'); + }); + + it('should match object with optional properties', () => { + const res = match({ x: 1 }) + .with(P.object.empty(), (obj) => { + type t = Expect>; + return 'no'; + }) + .with(P.object, (obj) => { + type t = Expect>; + return 'yes'; + }) + .exhaustive(); + expect(res).toEqual('yes'); + }); +}); From f32bfe5119ffc4470b33b45a1c144c3d8df7a521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EB=AF=BC?= Date: Fri, 29 Mar 2024 00:23:02 +0900 Subject: [PATCH 3/6] Fix multiple issues based on PR review feedback - Comment: Seems like chainable would be enough here, since you can't chain empty several times - Comment: I think we could make this more efficient by using a for in loop instead of Object.keys and breaking the loop by returning false if an object own property is encountered. - Comment: It should just be Chainable here as well - Comment: I'm not sure a new pattern type is necessary here because both patterns you added are implemented with guards - Comment: Could you add test covering how P.object behaves with more inputs: Functions, Primitive values, Null. It should catch all values that are assignable to the object type, and type narrowing and exhaustive should both work - Comment: Could you remove this diff? --- src/patterns.ts | 12 +++++---- src/types/Pattern.ts | 5 ++-- tests/object.test.ts | 63 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/patterns.ts b/src/patterns.ts index 9777c61c..72ef9089 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1141,16 +1141,18 @@ export function shape(pattern: UnknownPattern) { * .with(P.object.empty(), () => 'will match on empty objects') */ const emptyObject = (): GuardExcludeP => - when( - (value) => - value && typeof value === 'object' && Object.keys(value).length === 0 - ); + when((value) => { + if (!isObject(value)) return false; + + for (var prop in value) return false; + return true; + }); const objectChainable = >( pattern: pattern ): ObjectChainable => Object.assign(chainable(pattern), { - empty: () => objectChainable(intersection(pattern, emptyObject())), + empty: () => chainable(intersection(pattern, emptyObject())), }) as any; /** diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 010a23a0..9bfb40b6 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -10,7 +10,6 @@ export type MatcherType = | 'or' | 'and' | 'array' - | 'object' | 'map' | 'set' | 'select' @@ -93,7 +92,7 @@ export type CustomP = Matcher< export type ArrayP = Matcher; -export type ObjectP = Matcher; +export type ObjectP = Matcher; export type OptionalP = Matcher; @@ -674,7 +673,7 @@ export type ObjectChainable< * match(value) * .with(P.object.empty(), () => 'empty object') */ - empty(): ObjectChainable< + empty(): Chainable< ObjectP>, omitted | 'empty' >; diff --git a/tests/object.test.ts b/tests/object.test.ts index a2a45b75..93987a63 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -20,6 +20,66 @@ describe('Object', () => { expect(res).toEqual('not found'); }); + it('when input is a Function, it should not match as an exact object', () => { + const fn = () => () => {}; + + const res = match(fn()) + .with(P.object, (obj) => { + type t = Expect void>>; + return 'not found'; + }) + .otherwise(() => 'not found'); + expect(res).toEqual('not found'); + }) + + it('when input is a Number (a primitive value), it should not be matched as an exact object', () => { + const fn = () => 1_000_000; + + const res = match(fn()) + .with(P.object, (obj) => { + type t = Expect>; + return 'not found'; + }) + .otherwise(() => 'not found'); + expect(res).toEqual('not found'); + }) + + it('when input is a String (a primitive value), it should not be matched as an exact object', () => { + const fn = () => 'hello'; + + const res = match(fn()) + .with(P.object, (obj) => { + type t = Expect>; + return 'not found'; + }) + .otherwise(() => 'not found'); + expect(res).toEqual('not found'); + }) + + it('when input is a Boolean (a primitive value), it should not be matched as an exact object', () => { + const fn = () => true; + + const res = match(fn()) + .with(P.object, (obj) => { + type t = Expect>; + return 'not found'; + }) + .otherwise(() => 'not found'); + expect(res).toEqual('not found'); + }) + + it('when input is Null, it should not be matched as an exact object', () => { + const fn = () => null; + + const res = match(fn()) + .with(P.object, (obj) => { + type t = Expect>; + return 'not found'; + }) + .otherwise(() => 'not found'); + expect(res).toEqual('not found'); + }) + it('should match object with nested objects', () => { const res = match({ x: { y: 1 } }) .with({ x: { y: 1 } }, (obj) => { @@ -45,6 +105,7 @@ describe('Object', () => { return 'no'; }) .exhaustive(); + expect(res).toEqual('yes'); }); @@ -64,7 +125,7 @@ describe('Object', () => { expect(res).toEqual('yes'); }); - it('should match object with optional properties', () => { + it('should properly match an object against the P.object pattern, even with optional properties', () => { const res = match({ x: 1 }) .with(P.object.empty(), (obj) => { type t = Expect>; From 2f49cf230f993a751497c28b3ac88d847fb0e4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=A0=EB=AF=BC?= Date: Fri, 29 Mar 2024 21:00:54 +0900 Subject: [PATCH 4/6] Fix objectChainable type in patterns.ts and Pattern.ts --- src/patterns.ts | 4 ++-- src/types/Pattern.ts | 4 ++-- tests/object.test.ts | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/patterns.ts b/src/patterns.ts index 72ef9089..66686963 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1150,7 +1150,7 @@ const emptyObject = (): GuardExcludeP => const objectChainable = >( pattern: pattern -): ObjectChainable => +): ObjectChainable => Object.assign(chainable(pattern), { empty: () => chainable(intersection(pattern, emptyObject())), }) as any; @@ -1165,4 +1165,4 @@ const objectChainable = >( * match(value) * .with(P.object.empty(), () => 'will match on empty objects') **/ -export const object: ObjectChainable = objectChainable(when(isObject)); +export const object: ObjectChainable> = objectChainable(when(isObject)); diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 9bfb40b6..83edebec 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -674,9 +674,9 @@ export type ObjectChainable< * .with(P.object.empty(), () => 'empty object') */ empty(): Chainable< - ObjectP>, + GuardExcludeP, omitted | 'empty' - >; + >; }, omitted >; diff --git a/tests/object.test.ts b/tests/object.test.ts index 93987a63..8816bf18 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -117,7 +117,7 @@ describe('Object', () => { return 'yes'; }) .with(P.object, (obj) => { - type t = Expect>; + type t = Expect>; return 'no'; }) @@ -132,7 +132,9 @@ describe('Object', () => { return 'no'; }) .with(P.object, (obj) => { - type t = Expect>; + type t = Expect>; return 'yes'; }) .exhaustive(); From e6bf0afbbcbfea411fc343bc665702a199e36a62 Mon Sep 17 00:00:00 2001 From: gvergnaud Date: Sun, 31 Mar 2024 16:10:46 -0400 Subject: [PATCH 5/6] feat(P.object): Make P.object consistent with the object TS type --- README.md | 44 ++++++-- src/patterns.ts | 79 +++++++------- src/types/Pattern.ts | 24 +++-- tests/object.test.ts | 238 +++++++++++++++++++------------------------ 4 files changed, 194 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index 47bb531a..e6b2b847 100644 --- a/README.md +++ b/README.md @@ -1500,19 +1500,49 @@ console.log(fn(-3.141592), fn(7)); // logs '✅ ❌' ## `P.object` predicates -`P.object` has a number of methods to help you match on specific object. +`P.object` is itself a pattern, but also a module containing predicates related to object types. -### `P.object.empty` +### `P.object` -`P.object.empty` matches empty object +`P.object` matches any value assignable to the `object` TypeScript type. This includes all object literals, but also **arrays** and **functions**! + +`P.object` does not match primitive types, like strings or numbers. ```ts -const fn = (input: string) => +import { match, P } from 'ts-pattern'; + +const fn = (input: unknown) => match(input) - .with(P.object.empty(), () => 'Empty!') - .otherwise(() => 'Full!'); + .with(P.object, () => '✅') + .otherwise(() => '❌'); + +console.log(fn({})); // ✅ +console.log(fn({ hello: 'world!' })); // ✅ +console.log(fn([])); // ✅ +console.log(fn(() => {})); // ✅ + +console.log(fn(1, true, 'hi')); // ❌ ❌ ❌ +``` + +### `P.object.empty()` + +`P.object.empty()` matches the empty object `{}`: + +```ts +import { isMatching, P } from 'ts-pattern'; + +console.log(isMatching(P.object.empty(), {})); // true +``` + +`P.object.empty()` does **not** match empty arrays, 0 values or nullish values: + +```ts +import { isMatching, P } from 'ts-pattern'; -console.log(fn({})); // Empty! +console.log(isMatching(P.object.empty(), [])); // false +console.log(isMatching(P.object.empty(), 0)); // false +console.log(isMatching(P.object.empty(), null)); // false +console.log(isMatching(P.object.empty(), undefined)); // false ``` ## Types diff --git a/src/patterns.ts b/src/patterns.ts index 66686963..2056b9e1 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -36,6 +36,8 @@ import { Variadic, NonNullablePattern, ObjectChainable, + ObjectPattern, + EmptyObjectPattern, } from './types/Pattern'; export type { Pattern, Fn as unstable_Fn }; @@ -157,6 +159,14 @@ function arrayChainable>( }) as any; } +function objectChainable>( + pattern: pattern +): ObjectChainable { + return Object.assign(chainable(pattern), { + empty: () => emptyObject, + }) as any; +} + /** * `P.optional(subpattern)` takes a sub pattern and returns a pattern which matches if the * key is undefined or if it is defined and the sub pattern matches its value. @@ -640,8 +650,15 @@ function isNonNullable(x: unknown): x is {} { return x !== null && x !== undefined; } -function isObject(x: T | object): x is object { - return typeof x === 'object' && !Array.isArray(x) && x !== null; +function isObject(x: unknown): x is object { + return !!x && (typeof x === 'object' || typeof x === 'function'); +} + +function isEmptyObject(x: unknown) { + if (!x || typeof x !== 'object') return false; + if (Array.isArray(x)) return false; + for (const _key in x) return false; + return true; } type AnyConstructor = abstract new (...args: any[]) => any; @@ -1096,6 +1113,28 @@ export const nullish: NullishPattern = chainable(when(isNullish)); */ export const nonNullable: NonNullablePattern = chainable(when(isNonNullable)); +/** + * `P.object.empty()` is a pattern, matching **objects** with no keys. + * + * [Read the documentation for `P.object.empty()` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) + * + * @example + * match(value) + * .with(P.object.empty(), () => 'will match on empty objects') + */ +const emptyObject: EmptyObjectPattern = chainable(when(isEmptyObject)); + +/** + * `P.object` is a wildcard pattern, matching any **object**. + * + * [Read the documentation for `P.object` on GitHub](https://github.com/gvergnaud/ts-pattern#pobject-predicates) + * + * @example + * match(value) + * .with(P.object, () => 'will match on objects') + **/ +export const object: ObjectPattern = objectChainable(when(isObject)); + /** * `P.instanceOf(SomeClass)` is a pattern matching instances of a given class. * @@ -1130,39 +1169,3 @@ export function shape>( export function shape(pattern: UnknownPattern) { return chainable(when(isMatching(pattern))); } - -/** - * `P.object.empty()` is a pattern, matching **objects** with no keys. - * - * [Read the documentation for `P.object.empty` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) - * - * @example - * match(value) - * .with(P.object.empty(), () => 'will match on empty objects') - */ -const emptyObject = (): GuardExcludeP => - when((value) => { - if (!isObject(value)) return false; - - for (var prop in value) return false; - return true; - }); - -const objectChainable = >( - pattern: pattern -): ObjectChainable => - Object.assign(chainable(pattern), { - empty: () => chainable(intersection(pattern, emptyObject())), - }) as any; - -/** - * `P.object` is a wildcard pattern, matching any **object**. - * It lets you call methods like `.empty()`, `.and`, `.or` and `.select()` - * On structural patterns, like objects and arrays. - * [Read the documentation for `P.object` on GitHub](https://github.com/gvergnaud/ts-pattern#pobject-predicates) - * - * @example - * match(value) - * .with(P.object.empty(), () => 'will match on empty objects') - **/ -export const object: ObjectChainable> = objectChainable(when(isObject)); diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 83edebec..ef489904 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -159,10 +159,12 @@ type KnownPatternInternal< > = | primitives | PatternMatcher - | ([objs] extends [never] ? never : ObjectPattern>>) + | ([objs] extends [never] + ? never + : ObjectLiteralPattern>>) | ([arrays] extends [never] ? never : ArrayPattern); -type ObjectPattern = +type ObjectLiteralPattern = | { readonly [k in keyof a]?: Pattern; } @@ -188,6 +190,11 @@ export type NullishPattern = Chainable< GuardP, never >; +export type ObjectPattern = ObjectChainable, never>; +export type EmptyObjectPattern = Chainable< + GuardExcludeP, + never +>; export type NonNullablePattern = Chainable, never>; @@ -665,18 +672,15 @@ export type ObjectChainable< Omit< { /** - * `.empty()` matches an empty object. + * `P.object.empty()` is a pattern, matching **objects** with no keys. * - * [Read the documentation for `P.object.empty` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) + * [Read the documentation for `P.object.empty()` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectempty) * * @example - * match(value) - * .with(P.object.empty(), () => 'empty object') + * match(value) + * .with(P.object.empty(), () => 'will match on empty objects') */ - empty(): Chainable< - GuardExcludeP, - omitted | 'empty' - >; + empty: () => EmptyObjectPattern; }, omitted >; diff --git a/tests/object.test.ts b/tests/object.test.ts index 8816bf18..3c73efa7 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -1,143 +1,109 @@ -import { Expect, Equal } from '../src/types/helpers'; +import { Expect, Equal, Primitives } from '../src/types/helpers'; import { P, match } from '../src'; describe('Object', () => { - it('should match exact object', () => { - const fn = () => 'hello'; - - const res = match({ str: fn() }) - .with({ str: 'world' }, (obj) => { - type t = Expect>; - return obj.str; - }) - .with(P.object, (obj) => { - type t = Expect>; - return 'not found'; - }) - .exhaustive(); - expect(res).toEqual('not found'); - }); - - it('when input is a Function, it should not match as an exact object', () => { - const fn = () => () => {}; - - const res = match(fn()) - .with(P.object, (obj) => { - type t = Expect void>>; - return 'not found'; - }) - .otherwise(() => 'not found'); - expect(res).toEqual('not found'); - }) - - it('when input is a Number (a primitive value), it should not be matched as an exact object', () => { - const fn = () => 1_000_000; - - const res = match(fn()) - .with(P.object, (obj) => { - type t = Expect>; - return 'not found'; - }) - .otherwise(() => 'not found'); - expect(res).toEqual('not found'); - }) - - it('when input is a String (a primitive value), it should not be matched as an exact object', () => { - const fn = () => 'hello'; - - const res = match(fn()) - .with(P.object, (obj) => { - type t = Expect>; - return 'not found'; - }) - .otherwise(() => 'not found'); - expect(res).toEqual('not found'); - }) - - it('when input is a Boolean (a primitive value), it should not be matched as an exact object', () => { - const fn = () => true; - - const res = match(fn()) - .with(P.object, (obj) => { - type t = Expect>; - return 'not found'; - }) - .otherwise(() => 'not found'); - expect(res).toEqual('not found'); - }) - - it('when input is Null, it should not be matched as an exact object', () => { - const fn = () => null; - - const res = match(fn()) - .with(P.object, (obj) => { - type t = Expect>; - return 'not found'; - }) - .otherwise(() => 'not found'); - expect(res).toEqual('not found'); - }) - - it('should match object with nested objects', () => { - const res = match({ x: { y: 1 } }) - .with({ x: { y: 1 } }, (obj) => { - type t = Expect>; - return 'yes'; - }) - .with(P.object, (obj) => { - type t = Expect>; - return 'no'; - }) - .exhaustive(); - expect(res).toEqual('yes'); - }); - - it('should match object with nested objects and arrays', () => { - const res = match({ x: { y: [1] } }) - .with({ x: { y: [1] } }, (obj) => { - type t = Expect>; - return 'yes'; - }) - .with(P.object, (obj) => { - type t = Expect>; - return 'no'; - }) - .exhaustive(); - - expect(res).toEqual('yes'); - }); - - it('should match empty object', () => { - const res = match({}) - .with(P.object.empty(), (obj) => { - type t = Expect>; - - return 'yes'; - }) - .with(P.object, (obj) => { - type t = Expect>; - - return 'no'; - }) - .exhaustive(); - expect(res).toEqual('yes'); + describe('P.object', () => { + describe('exhaustiveness checking', () => { + it("shouldn't match primitive types", () => { + const fn = (input: Primitives | object) => + match(input) + .with(P.object, (obj) => { + type t = Expect>; + return 'object'; + }) + // @ts-expect-error primitive types aren't assignable to `object` + .exhaustive(); + + expect(fn({ k: 'hello' })).toEqual('object'); + expect(() => fn('hello')).toThrow(); + }); + + it('should match functions', () => { + const fn = (input: () => void) => + match(input) + .with(P.object, (obj) => { + type t = Expect void>>; + return 'object'; + }) + // `() => void` is assignable to `object` + .exhaustive(); + expect(fn(() => {})).toEqual('object'); + }); + + it('should match object literals', () => { + const fn = (input: { hello: 'world' }) => + match(input) + .with(P.object, (obj) => { + type t = Expect>; + return 'object'; + }) + // `{ hello: 'world' }` is assignable to `object` + .exhaustive(); + expect(fn({ hello: 'world' })).toEqual('object'); + }); + + it('should match arrays', () => { + const fn = (input: string[] | [1, 2] | [] | readonly ['a', 'b']) => + match(input) + .with(P.object, (obj) => { + type t = Expect< + Equal + >; + return 'object'; + }) + // all arrays are assignable to `object` + .exhaustive(); + expect(fn(['a', 'b'])).toEqual('object'); + expect(fn(['aasdasd'])).toEqual('object'); + expect(fn([])).toEqual('object'); + expect(fn([1, 2])).toEqual('object'); + }); + + it('should match records', () => { + const fn = (input: Record) => { + match(input) + .with(P.object, (obj) => { + type t = Expect>>; + return 'object'; + }) + // records are assignable to `object`. + .exhaustive(); + expect(fn({ a: 'b' })).toEqual('object'); + }; + }); + }); }); - it('should properly match an object against the P.object pattern, even with optional properties', () => { - const res = match({ x: 1 }) - .with(P.object.empty(), (obj) => { - type t = Expect>; - return 'no'; - }) - .with(P.object, (obj) => { - type t = Expect>; - return 'yes'; - }) - .exhaustive(); - expect(res).toEqual('yes'); + describe('P.object.empty()', () => { + it('should only catch the literal `{}`.', () => { + const fn = (input: object) => + match(input) + .with(P.object.empty(), (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(); + }); + + it('should not catch the primitive types', () => { + const fn = (input: unknown) => + match(input) + .with(P.object.empty(), (obj) => { + type t = Expect>; + return 'yes'; + }) + .otherwise(() => 'no'); + + expect(fn({})).toEqual('yes'); + expect(fn(0)).toEqual('no'); + expect(fn(0n)).toEqual('no'); + expect(fn(null)).toEqual('no'); + }); }); }); From 6cc4989470225ae45ef4a1b0fc57355c5ca9ad06 Mon Sep 17 00:00:00 2001 From: gvergnaud Date: Sun, 31 Mar 2024 16:30:59 -0400 Subject: [PATCH 6/6] feat(P.object): Add P.object.exact --- src/patterns.ts | 35 +++++++++++++++++++++++++++++++++-- src/types/Pattern.ts | 19 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/patterns.ts b/src/patterns.ts index 2056b9e1..4d6f3245 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -38,6 +38,7 @@ import { ObjectChainable, ObjectPattern, EmptyObjectPattern, + ObjectLiteralPattern, } from './types/Pattern'; export type { Pattern, Fn as unstable_Fn }; @@ -164,6 +165,7 @@ function objectChainable>( ): ObjectChainable { return Object.assign(chainable(pattern), { empty: () => emptyObject, + exact: exactObject, }) as any; } @@ -654,13 +656,17 @@ function isObject(x: unknown): x is object { return !!x && (typeof x === 'object' || typeof x === 'function'); } -function isEmptyObject(x: unknown) { +function hasExactKeys(keys: Set, x: unknown) { if (!x || typeof x !== 'object') return false; if (Array.isArray(x)) return false; - for (const _key in x) return false; + for (const key in x) if (!keys.has(key)) return false; return true; } +function isEmptyObject(x: unknown) { + return hasExactKeys(new Set(), x); +} + type AnyConstructor = abstract new (...args: any[]) => any; function isInstanceOf(classConstructor: T) { @@ -1124,6 +1130,31 @@ export const nonNullable: NonNullablePattern = chainable(when(isNonNullable)); */ const emptyObject: EmptyObjectPattern = chainable(when(isEmptyObject)); +/** + * `P.object.exact({...})` matching objects that contain exactly the set of defined in the pattern. Objects with additional keys won't match this pattern, even if keys defined in both the pattern and the object match. + * + * [Read the documentation for `P.object.exact()` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectexact) + * + * @example + * match(value) + * .with( + * P.object.exact({ a: P.any }), + * () => 'Objects with a single `a` key that contains anything.' + * ) + */ +export function exactObject< + input, + const pattern extends ObjectLiteralPattern +>( + pattern: pattern +): Chainable, never>>; +export function exactObject(pattern: ObjectLiteralPattern<{}>) { + const patternKeys = new Set(Object.keys(pattern)); + return chainable( + when((input) => isMatching(pattern) && hasExactKeys(patternKeys, input)) + ); +} + /** * `P.object` is a wildcard pattern, matching any **object**. * diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index ef489904..6ca297c1 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -3,6 +3,7 @@ import { MergeUnion, Primitives, WithDefault } from './helpers'; import { None, Some, SelectionType } from './FindSelected'; import { matcher } from '../patterns'; import { ExtractPreciseValue } from './ExtractPreciseValue'; +import { InvertPattern } from './InvertPattern'; export type MatcherType = | 'not' @@ -164,7 +165,7 @@ type KnownPatternInternal< : ObjectLiteralPattern>>) | ([arrays] extends [never] ? never : ArrayPattern); -type ObjectLiteralPattern = +export type ObjectLiteralPattern = | { readonly [k in keyof a]?: Pattern; } @@ -681,6 +682,22 @@ export type ObjectChainable< * .with(P.object.empty(), () => 'will match on empty objects') */ empty: () => EmptyObjectPattern; + + /** + * `P.object.exact({...})` matching objects that contain exactly the set of defined in the pattern. Objects with additional keys won't match this pattern, even if keys defined in both the pattern and the object match. + * + * [Read the documentation for `P.object.exact()` on GitHub](https://github.com/gvergnaud/ts-pattern#pobjectexact) + * + * @example + * match(value) + * .with( + * P.object.exact({ a: P.any }), + * () => 'Objects with a single `a` key that contains anything.' + * ) + */ + >( + pattern: pattern + ): Chainable, never>>; }, omitted >;