Skip to content

Commit

Permalink
Merge pull request #237 from gvergnaud/fix-p-nonnullable-narrowing
Browse files Browse the repository at this point in the history
Fix(P.nonNullable): narrowing of unions of objects
  • Loading branch information
gvergnaud committed Apr 6, 2024
2 parents 72e1079 + bf40fbb commit 19ae81e
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 100 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 36 additions & 19 deletions src/types/ExtractPreciseValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Override } from './Pattern';
import type {
BuiltInObjects,
Compute,
ExcludeObjectIfContainsNever,
Contains,
IsPlainObject,
IsReadonlyArray,
LeastUpperBound,
Expand Down Expand Up @@ -37,27 +37,44 @@ export type ExtractPreciseValue<a, b> = b extends Override<infer b1>
? a extends b
? a
: b extends a
? [Exclude<keyof a, keyof b>] extends [never]
? Contains<b, never> 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<Omit<b, keyof a>, {}> extends true
? never
: // If values have no keys in common, return `b`
[Exclude<keyof a, keyof b>] extends [never]
? b
: Compute<ExcludeObjectIfContainsNever<b> & Omit<a, keyof b>>
: // 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<b & Omit<a, keyof b>>
: [keyof a & keyof b] extends [never]
? never
: ExcludeObjectIfContainsNever<
Compute<
// Keep other properties of `a`
{
[k in Exclude<keyof a, keyof b>]: 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<a[k], b[k]>
: b[k];
}
>,
keyof b & string
>
: Compute<
// Keep other properties of `a`
{
// `in keyof a as ...` preserves property modifiers,
// unlike `in keyof Exclude<keyof a, keyof b>`.
[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<a[k], b[k]>
: b[k];
}
> extends infer result
? Contains<Pick<result, keyof result & keyof b>, never> extends true
? never
: result
: never
: LeastUpperBound<a, b>
: LeastUpperBound<a, b>;

Expand Down
28 changes: 5 additions & 23 deletions src/types/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,12 @@ export type Values<a extends object> = UnionToTuple<ValueOf<a>>;

export type LeastUpperBound<a, b> = 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<a, b> = b extends Map<any, any> | Set<any>
? a
: b extends readonly [any, ...any]
? ExcludeObjectIfContainsNever<a, keyof b & ('0' | '1' | '2' | '3' | '4')>
: b extends readonly any[]
? ExcludeObjectIfContainsNever<a, keyof b & number>
: ExcludeObjectIfContainsNever<a, keyof b & string>;

export type ExcludeObjectIfContainsNever<
a,
keyConstraint = unknown
> = a extends any
export type Contains<a, b> = 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<a[k], b> extends true ? 'exclude' : 'include';
}[keyof a]
? true
: false
: never;

// from https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
Expand Down
51 changes: 51 additions & 0 deletions tests/extract-precise-value.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -292,6 +294,55 @@ describe('ExtractPreciseValue', () => {
});
});

describe('non-nullable patterns', () => {
type nonNullable = InvertPattern<NonNullablePattern, unknown>;

it('should exclude objects if the absent', () => {
type res1 = ExtractPreciseValue<{ a: string }, { b: nonNullable }>;
type test1 = Expect<Equal<res1, never>>;

type res2 = ExtractPreciseValue<
{ a: string } | { b: number },
{ b: nonNullable }
>;
type test2 = Expect<Equal<res2, { b: number }>>;

type res3 = ExtractPreciseValue<
{ a: string } | { b: number } | { b: string; c: boolean },
{ b: nonNullable }
>;
type test3 = Expect<
Equal<res3, { b: number } | { b: string; c: boolean }>
>;
});

it('should keep empty objects if they come from the input type', () => {
type res1 = ExtractPreciseValue<
{ a: string } | { b: {} },
{ b: nonNullable }
>;
type test1 = Expect<Equal<res1, { b: {} }>>;
});

it('should exclude objects even if the non-nullable key is deeply nested', () => {
type res1 = ExtractPreciseValue<{ a: number }, { b: { c: nonNullable } }>;
type test1 = Expect<Equal<res1, never>>;

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
Expand Down
35 changes: 0 additions & 35 deletions tests/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
Drop,
Equal,
ExcludeIfContainsNever,
Expect,
Iterator,
LeastUpperBound,
Expand Down Expand Up @@ -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 {}
Expand Down
6 changes: 4 additions & 2 deletions tests/type-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Equal<typeof x, { a: never }>>;
type t = Expect<Equal<typeof x, typeof input>>;
return 'Ok';
})
.otherwise(() => 'ko');
Expand Down
74 changes: 56 additions & 18 deletions tests/wildcards.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<Input>(false)
.with(P.nonNullable, (x) => {
type t = Expect<Equal<typeof x, string | number | boolean>>;
return true;
})
.otherwise(() => false);

const res2 = match<0 | 1 | 2 | null>(0)
.with(P.nonNullable, (x) => {
type t = Expect<Equal<typeof x, 0 | 1 | 2>>;
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<Input>(false)
.with(P.nonNullable, (x) => {
type t = Expect<Equal<typeof x, string | number | boolean>>;
return true;
})
.otherwise(() => false);

const res2 = match<0 | 1 | 2 | null>(0)
.with(P.nonNullable, (x) => {
type t = Expect<Equal<typeof x, 0 | 1 | 2>>;
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<typeof pattern, Input>;
type Y = ExtractPreciseValue<Input, X>;

const fn = (data: Input) =>
match(data)
.with(
{ __typename: 'ValidationRejection', fields: P.nonNullable },
({ fields }) => {
type t = Expect<Equal<typeof fields, string[]>>;
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', () => {
Expand Down

0 comments on commit 19ae81e

Please sign in to comment.