diff --git a/README.md b/README.md index 13a27876..7e6613a9 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` diff --git a/src/patterns.ts b/src/patterns.ts index fa11bd59..cc7a53d1 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,40 @@ 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 47d7821c..6dde6454 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -97,6 +97,8 @@ export type CustomP = Matcher< export type ArrayP = Matcher; +export type ObjectP = Matcher; + export type OptionalP = Matcher; export type MapP = Matcher; @@ -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(): Chainable< + GuardExcludeP, + 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..8816bf18 --- /dev/null +++ b/tests/object.test.ts @@ -0,0 +1,143 @@ +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('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'); + }); + + 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'); + }); +});