diff --git a/package-lock.json b/package-lock.json index eb5baf3b..82041012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ts-pattern", - "version": "5.1.0", + "version": "5.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ts-pattern", - "version": "5.1.0", + "version": "5.1.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.2", diff --git a/package.json b/package.json index 729e325a..67dd71d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-pattern", - "version": "5.1.0", + "version": "5.1.1", "description": " The exhaustive Pattern Matching library for TypeScript.", "type": "module", "source": "src/index.ts", diff --git a/src/types/ExtractPreciseValue.ts b/src/types/ExtractPreciseValue.ts index 724d6aa2..b1ab9f96 100644 --- a/src/types/ExtractPreciseValue.ts +++ b/src/types/ExtractPreciseValue.ts @@ -2,7 +2,7 @@ import type { Override } from './Pattern'; import type { BuiltInObjects, Compute, - ExcludeObjectIfContainsNever, + Contains, IsPlainObject, IsReadonlyArray, LeastUpperBound, @@ -37,27 +37,44 @@ export type ExtractPreciseValue = b extends Override ? a extends b ? a : b extends a - ? [Exclude] extends [never] + ? Contains extends true + ? never + : // An empty object `{}` in a pattern means + // that this key must be non-nullable. + // If we find a key in `b` that doesn't exist in `a` + // and that contains `{}`, then the pattern does not match. + Contains, {}> extends true + ? never + : // If values have no keys in common, return `b` + [Exclude] extends [never] ? b - : Compute & Omit> + : // Otherwise return `b` with keys of `a` + // that do not exist on `b`. + // It can only be optional properties, + // otherwise `b extends a` wouldn't + // not have passed. + Compute> : [keyof a & keyof b] extends [never] ? never - : ExcludeObjectIfContainsNever< - Compute< - // Keep other properties of `a` - { - [k in Exclude]: a[k]; - } & { - // use `b` to extract precise values on `a`. - // This has the effect of preserving the optional - // property modifier (?:) of b in the output type. - [k in keyof b]: k extends keyof a - ? ExtractPreciseValue - : b[k]; - } - >, - keyof b & string - > + : Compute< + // Keep other properties of `a` + { + // `in keyof a as ...` preserves property modifiers, + // unlike `in keyof Exclude`. + [k in keyof a as k extends keyof b ? never : k]: a[k]; + } & { + // use `b` to extract precise values on `a`. + // This has the effect of preserving the optional + // property modifier (?:) of b in the output type. + [k in keyof b]: k extends keyof a + ? ExtractPreciseValue + : b[k]; + } + > extends infer result + ? Contains, never> extends true + ? never + : result + : never : LeastUpperBound : LeastUpperBound; diff --git a/src/types/helpers.ts b/src/types/helpers.ts index 116f8b29..f62b1943 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -13,30 +13,12 @@ export type Values = UnionToTuple>; export type LeastUpperBound = b extends a ? b : a extends b ? a : never; -/** - * if a key of an object has the never type, - * returns never, otherwise returns the type of object - **/ - -export type ExcludeIfContainsNever = b extends Map | Set - ? a - : b extends readonly [any, ...any] - ? ExcludeObjectIfContainsNever - : b extends readonly any[] - ? ExcludeObjectIfContainsNever - : ExcludeObjectIfContainsNever; - -export type ExcludeObjectIfContainsNever< - a, - keyConstraint = unknown -> = a extends any +export type Contains = a extends any ? 'exclude' extends { - [k in keyConstraint & keyof a]-?: [a[k]] extends [never] - ? 'exclude' - : 'include'; - }[keyConstraint & keyof a] - ? never - : a + [k in keyof a]-?: Equal extends true ? 'exclude' : 'include'; + }[keyof a] + ? true + : false : never; // from https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286 diff --git a/tests/extract-precise-value.test.ts b/tests/extract-precise-value.test.ts index 88464759..8d8b01c3 100644 --- a/tests/extract-precise-value.test.ts +++ b/tests/extract-precise-value.test.ts @@ -1,4 +1,6 @@ import { ExtractPreciseValue } from '../src/types/ExtractPreciseValue'; +import { InvertPattern } from '../src/types/InvertPattern'; +import { NonNullablePattern } from '../src/types/Pattern'; import { Expect, Equal } from '../src/types/helpers'; import { AsyncResult, Event, Option, State } from './types-catalog/utils'; @@ -292,6 +294,55 @@ describe('ExtractPreciseValue', () => { }); }); + describe('non-nullable patterns', () => { + type nonNullable = InvertPattern; + + it('should exclude objects if the absent', () => { + type res1 = ExtractPreciseValue<{ a: string }, { b: nonNullable }>; + type test1 = Expect>; + + type res2 = ExtractPreciseValue< + { a: string } | { b: number }, + { b: nonNullable } + >; + type test2 = Expect>; + + type res3 = ExtractPreciseValue< + { a: string } | { b: number } | { b: string; c: boolean }, + { b: nonNullable } + >; + type test3 = Expect< + Equal + >; + }); + + it('should keep empty objects if they come from the input type', () => { + type res1 = ExtractPreciseValue< + { a: string } | { b: {} }, + { b: nonNullable } + >; + type test1 = Expect>; + }); + + it('should exclude objects even if the non-nullable key is deeply nested', () => { + type res1 = ExtractPreciseValue<{ a: number }, { b: { c: nonNullable } }>; + type test1 = Expect>; + + type res2 = ExtractPreciseValue< + | { nested: { a: string } } + | { nested: { b: number } } + | { nested: { b: string; c: boolean } }, + { nested: { b: nonNullable } } + >; + type test2 = Expect< + Equal< + res2, + { nested: { b: number } } | { nested: { b: string; c: boolean } } + > + >; + }); + }); + describe('Branded strings', () => { it('Type narrowing should correctly work on branded strings', () => { // Branded strings is a commonly used way of implementing diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 891fad5b..058e96a5 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -1,7 +1,6 @@ import { Drop, Equal, - ExcludeIfContainsNever, Expect, Iterator, LeastUpperBound, @@ -74,40 +73,6 @@ describe('helpers', () => { ]; }); - describe('ExcludeIfContainsNever', () => { - it('should work with objects and tuples', () => { - type cases = [ - Expect< - Equal< - ExcludeIfContainsNever< - { kind: 'some'; value: string } | { kind: never }, - { kind: 'some' } - >, - { kind: 'some'; value: string } - > - >, - Expect< - Equal< - ExcludeIfContainsNever< - [{ kind: 'some'; value: string } | never], - [{ kind: 'some' }] - >, - [{ kind: 'some'; value: string }] - > - >, - Expect< - Equal< - ExcludeIfContainsNever< - [{ kind: 'some'; value: string }, never], - [{ kind: 'some' }, unknown] - >, - never - > - > - ]; - }); - }); - describe('LeastUpperBound', () => { it('If both a and b extend each other, it should pick b', () => { class B {} diff --git a/tests/type-error.test.ts b/tests/type-error.test.ts index 9fb84519..078f844c 100644 --- a/tests/type-error.test.ts +++ b/tests/type-error.test.ts @@ -119,9 +119,11 @@ describe('type errors', () => { it("if a pattern is any, the outer expression shouldn't throw a type error", () => { const anyVar = null as any; - match({ a: 'a' }) + const input = { a: 'a' }; + + match(input) .with({ a: anyVar }, (x) => { - type t = Expect>; + type t = Expect>; return 'Ok'; }) .otherwise(() => 'ko'); diff --git a/tests/wildcards.test.ts b/tests/wildcards.test.ts index 168c9e1f..c383d006 100644 --- a/tests/wildcards.test.ts +++ b/tests/wildcards.test.ts @@ -1,6 +1,8 @@ import { Expect, Equal } from '../src/types/helpers'; import { match, P } from '../src'; import { Blog } from './types-catalog/utils'; +import { InvertPattern } from '../src/types/InvertPattern'; +import { ExtractPreciseValue } from '../src/types/ExtractPreciseValue'; describe('wildcards', () => { it('should match String wildcards', () => { @@ -56,25 +58,61 @@ describe('wildcards', () => { expect(res2).toEqual(true); }); - it('should match nonNullable wildcard', () => { - type Input = string | number | boolean | null | undefined; - const res = match(false) - .with(P.nonNullable, (x) => { - type t = Expect>; - return true; - }) - .otherwise(() => false); - - const res2 = match<0 | 1 | 2 | null>(0) - .with(P.nonNullable, (x) => { - type t = Expect>; - return true; - }) - .with(null, () => false) - .exhaustive(); + describe('P.nonNullable', () => { + it('should narrow primitive types correctly', () => { + type Input = string | number | boolean | null | undefined; + const res = match(false) + .with(P.nonNullable, (x) => { + type t = Expect>; + return true; + }) + .otherwise(() => false); + + const res2 = match<0 | 1 | 2 | null>(0) + .with(P.nonNullable, (x) => { + type t = Expect>; + return true; + }) + .with(null, () => false) + .exhaustive(); + + expect(res).toEqual(true); + expect(res2).toEqual(true); + }); - expect(res).toEqual(true); - expect(res2).toEqual(true); + it('should narrow object types correctly', () => { + type Input = + | { + __typename: 'ValidationRejection'; + fields: string[]; + } + | { + __typename: 'ValidationRejection'; + }; + + const pattern = { + __typename: 'ValidationRejection', + fields: P.nonNullable, + } as const; + type X = InvertPattern; + type Y = ExtractPreciseValue; + + const fn = (data: Input) => + match(data) + .with( + { __typename: 'ValidationRejection', fields: P.nonNullable }, + ({ fields }) => { + type t = Expect>; + return 'matched'; + } + ) + .otherwise(() => 'did not match'); + + expect(fn({ __typename: 'ValidationRejection' })).toBe('did not match'); + expect(fn({ __typename: 'ValidationRejection', fields: [] })).toBe( + 'matched' + ); + }); }); it('should match String, Number and Boolean wildcards', () => {