diff --git a/README.md b/README.md index c77bfbfa..e6b2b847 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,53 @@ const fn = (input: number) => console.log(fn(-3.141592), fn(7)); // logs '✅ ❌' ``` +## `P.object` predicates + +`P.object` is itself a pattern, but also a module containing predicates related to object types. + +### `P.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 +import { match, P } from 'ts-pattern'; + +const fn = (input: unknown) => + match(input) + .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(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 ### `P.infer` diff --git a/src/patterns.ts b/src/patterns.ts index c00e6842..4d6f3245 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -35,6 +35,10 @@ import { ArrayChainable, Variadic, NonNullablePattern, + ObjectChainable, + ObjectPattern, + EmptyObjectPattern, + ObjectLiteralPattern, } from './types/Pattern'; export type { Pattern, Fn as unstable_Fn }; @@ -156,6 +160,15 @@ function arrayChainable>( }) as any; } +function objectChainable>( + pattern: pattern +): ObjectChainable { + return Object.assign(chainable(pattern), { + empty: () => emptyObject, + exact: exactObject, + }) 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. @@ -639,6 +652,21 @@ function isNonNullable(x: unknown): x is {} { return x !== null && x !== undefined; } +function isObject(x: unknown): x is object { + return !!x && (typeof x === 'object' || typeof x === 'function'); +} + +function hasExactKeys(keys: Set, x: unknown) { + if (!x || typeof x !== 'object') return false; + if (Array.isArray(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) { @@ -1091,6 +1119,53 @@ 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.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**. + * + * [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. * diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 2d2e610e..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' @@ -92,6 +93,8 @@ export type CustomP = Matcher< export type ArrayP = Matcher; +export type ObjectP = Matcher; + export type OptionalP = Matcher; export type MapP = Matcher; @@ -157,10 +160,12 @@ type KnownPatternInternal< > = | primitives | PatternMatcher - | ([objs] extends [never] ? never : ObjectPattern>>) + | ([objs] extends [never] + ? never + : ObjectLiteralPattern>>) | ([arrays] extends [never] ? never : ArrayPattern); -type ObjectPattern = +export type ObjectLiteralPattern = | { readonly [k in keyof a]?: Pattern; } @@ -186,6 +191,11 @@ export type NullishPattern = Chainable< GuardP, never >; +export type ObjectPattern = ObjectChainable, never>; +export type EmptyObjectPattern = Chainable< + GuardExcludeP, + never +>; export type NonNullablePattern = Chainable, never>; @@ -655,3 +665,39 @@ export type ArrayChainable< }, omitted >; + +export type ObjectChainable< + pattern, + omitted extends string = never +> = Chainable & + Omit< + { + /** + * `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') + */ + 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 + >; diff --git a/tests/object.test.ts b/tests/object.test.ts new file mode 100644 index 00000000..3c73efa7 --- /dev/null +++ b/tests/object.test.ts @@ -0,0 +1,109 @@ +import { Expect, Equal, Primitives } from '../src/types/helpers'; +import { P, match } from '../src'; + +describe('Object', () => { + 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'); + }; + }); + }); + }); + + 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'); + }); + }); +});