From 27f8749e5cda71c4c5bef19f8392c95c54713f47 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/4] docs: add P.object on README.md --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 13a27876..c54939da 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) @@ -1482,6 +1483,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 ea7a9379250523da23ae79c7f62b3a530bbbad4c 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/4] Add object, object.empty pattern and tests --- README.md | 2 +- src/patterns.ts | 39 +++++++++++++++++++++ src/types/Pattern.ts | 27 ++++++++++++++- tests/object.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/object.test.ts diff --git a/README.md b/README.md index c54939da..7e6613a9 100644 --- a/README.md +++ b/README.md @@ -1497,7 +1497,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 fa11bd59..4ef9eb7d 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -34,6 +34,7 @@ import { StringChainable, ArrayChainable, Variadic, + ObjectChainable, } from './types/Pattern'; export type { Pattern, Fn as unstable_Fn }; @@ -634,6 +635,12 @@ function isNullish(x: T | null | undefined): x is null | undefined { 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) { @@ -1110,3 +1117,35 @@ 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 47d7821c..d6fb8913 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -15,6 +15,7 @@ export type MatcherType = | 'or' | 'and' | 'array' + | 'object' | 'map' | 'set' | 'select' @@ -97,6 +98,8 @@ export type CustomP = Matcher< export type ArrayP = Matcher; +export type ObjectP = Matcher; + export type OptionalP = Matcher; export type MapP = Matcher; @@ -191,7 +194,6 @@ export type NullishPattern = Chainable< GuardP, never >; - type MergeGuards = [guard1, guard2] extends [ GuardExcludeP, GuardExcludeP @@ -658,3 +660,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 + >; \ No newline at end of file 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 1831e293960c2b6132d33099d227b94169e1c8d7 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/4] 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 | 9 +++++-- src/types/Pattern.ts | 6 ++--- tests/object.test.ts | 63 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/patterns.ts b/src/patterns.ts index 4ef9eb7d..ebca0ea0 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1128,14 +1128,19 @@ 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, + (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 d6fb8913..9809b605 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -15,7 +15,6 @@ export type MatcherType = | 'or' | 'and' | 'array' - | 'object' | 'map' | 'set' | 'select' @@ -98,7 +97,7 @@ export type CustomP = Matcher< export type ArrayP = Matcher; -export type ObjectP = Matcher; +export type ObjectP = Matcher; export type OptionalP = Matcher; @@ -194,6 +193,7 @@ export type NullishPattern = Chainable< GuardP, never >; + type MergeGuards = [guard1, guard2] extends [ GuardExcludeP, GuardExcludeP @@ -676,7 +676,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 f17c02c6977e400cf7a6ef972592df6e1bb1e486 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/4] 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 ebca0ea0..cc7a53d1 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1138,7 +1138,7 @@ const emptyObject = (): GuardExcludeP => when( const objectChainable = >( pattern: pattern -): ObjectChainable => +): ObjectChainable => Object.assign(chainable(pattern), { empty: () => chainable(intersection(pattern, emptyObject())), }) as any; @@ -1153,4 +1153,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 9809b605..6dde6454 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -677,9 +677,9 @@ export type ObjectChainable< * .with(P.object.empty(), () => 'empty object') */ empty(): Chainable< - ObjectP>, + GuardExcludeP, omitted | 'empty' - >; + >; }, omitted >; \ No newline at end of file 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();