Skip to content

Commit 19ae81e

Browse files
authored
Merge pull request #237 from gvergnaud/fix-p-nonnullable-narrowing
Fix(P.nonNullable): narrowing of unions of objects
2 parents 72e1079 + bf40fbb commit 19ae81e

8 files changed

+155
-100
lines changed

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ts-pattern",
3-
"version": "5.1.0",
3+
"version": "5.1.1",
44
"description": " The exhaustive Pattern Matching library for TypeScript.",
55
"type": "module",
66
"source": "src/index.ts",

src/types/ExtractPreciseValue.ts

+36-19
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Override } from './Pattern';
22
import type {
33
BuiltInObjects,
44
Compute,
5-
ExcludeObjectIfContainsNever,
5+
Contains,
66
IsPlainObject,
77
IsReadonlyArray,
88
LeastUpperBound,
@@ -37,27 +37,44 @@ export type ExtractPreciseValue<a, b> = b extends Override<infer b1>
3737
? a extends b
3838
? a
3939
: b extends a
40-
? [Exclude<keyof a, keyof b>] extends [never]
40+
? Contains<b, never> extends true
41+
? never
42+
: // An empty object `{}` in a pattern means
43+
// that this key must be non-nullable.
44+
// If we find a key in `b` that doesn't exist in `a`
45+
// and that contains `{}`, then the pattern does not match.
46+
Contains<Omit<b, keyof a>, {}> extends true
47+
? never
48+
: // If values have no keys in common, return `b`
49+
[Exclude<keyof a, keyof b>] extends [never]
4150
? b
42-
: Compute<ExcludeObjectIfContainsNever<b> & Omit<a, keyof b>>
51+
: // Otherwise return `b` with keys of `a`
52+
// that do not exist on `b`.
53+
// It can only be optional properties,
54+
// otherwise `b extends a` wouldn't
55+
// not have passed.
56+
Compute<b & Omit<a, keyof b>>
4357
: [keyof a & keyof b] extends [never]
4458
? never
45-
: ExcludeObjectIfContainsNever<
46-
Compute<
47-
// Keep other properties of `a`
48-
{
49-
[k in Exclude<keyof a, keyof b>]: a[k];
50-
} & {
51-
// use `b` to extract precise values on `a`.
52-
// This has the effect of preserving the optional
53-
// property modifier (?:) of b in the output type.
54-
[k in keyof b]: k extends keyof a
55-
? ExtractPreciseValue<a[k], b[k]>
56-
: b[k];
57-
}
58-
>,
59-
keyof b & string
60-
>
59+
: Compute<
60+
// Keep other properties of `a`
61+
{
62+
// `in keyof a as ...` preserves property modifiers,
63+
// unlike `in keyof Exclude<keyof a, keyof b>`.
64+
[k in keyof a as k extends keyof b ? never : k]: a[k];
65+
} & {
66+
// use `b` to extract precise values on `a`.
67+
// This has the effect of preserving the optional
68+
// property modifier (?:) of b in the output type.
69+
[k in keyof b]: k extends keyof a
70+
? ExtractPreciseValue<a[k], b[k]>
71+
: b[k];
72+
}
73+
> extends infer result
74+
? Contains<Pick<result, keyof result & keyof b>, never> extends true
75+
? never
76+
: result
77+
: never
6178
: LeastUpperBound<a, b>
6279
: LeastUpperBound<a, b>;
6380

src/types/helpers.ts

+5-23
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,12 @@ export type Values<a extends object> = UnionToTuple<ValueOf<a>>;
1313

1414
export type LeastUpperBound<a, b> = b extends a ? b : a extends b ? a : never;
1515

16-
/**
17-
* if a key of an object has the never type,
18-
* returns never, otherwise returns the type of object
19-
**/
20-
21-
export type ExcludeIfContainsNever<a, b> = b extends Map<any, any> | Set<any>
22-
? a
23-
: b extends readonly [any, ...any]
24-
? ExcludeObjectIfContainsNever<a, keyof b & ('0' | '1' | '2' | '3' | '4')>
25-
: b extends readonly any[]
26-
? ExcludeObjectIfContainsNever<a, keyof b & number>
27-
: ExcludeObjectIfContainsNever<a, keyof b & string>;
28-
29-
export type ExcludeObjectIfContainsNever<
30-
a,
31-
keyConstraint = unknown
32-
> = a extends any
16+
export type Contains<a, b> = a extends any
3317
? 'exclude' extends {
34-
[k in keyConstraint & keyof a]-?: [a[k]] extends [never]
35-
? 'exclude'
36-
: 'include';
37-
}[keyConstraint & keyof a]
38-
? never
39-
: a
18+
[k in keyof a]-?: Equal<a[k], b> extends true ? 'exclude' : 'include';
19+
}[keyof a]
20+
? true
21+
: false
4022
: never;
4123

4224
// from https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286

tests/extract-precise-value.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { ExtractPreciseValue } from '../src/types/ExtractPreciseValue';
2+
import { InvertPattern } from '../src/types/InvertPattern';
3+
import { NonNullablePattern } from '../src/types/Pattern';
24
import { Expect, Equal } from '../src/types/helpers';
35
import { AsyncResult, Event, Option, State } from './types-catalog/utils';
46

@@ -292,6 +294,55 @@ describe('ExtractPreciseValue', () => {
292294
});
293295
});
294296

297+
describe('non-nullable patterns', () => {
298+
type nonNullable = InvertPattern<NonNullablePattern, unknown>;
299+
300+
it('should exclude objects if the absent', () => {
301+
type res1 = ExtractPreciseValue<{ a: string }, { b: nonNullable }>;
302+
type test1 = Expect<Equal<res1, never>>;
303+
304+
type res2 = ExtractPreciseValue<
305+
{ a: string } | { b: number },
306+
{ b: nonNullable }
307+
>;
308+
type test2 = Expect<Equal<res2, { b: number }>>;
309+
310+
type res3 = ExtractPreciseValue<
311+
{ a: string } | { b: number } | { b: string; c: boolean },
312+
{ b: nonNullable }
313+
>;
314+
type test3 = Expect<
315+
Equal<res3, { b: number } | { b: string; c: boolean }>
316+
>;
317+
});
318+
319+
it('should keep empty objects if they come from the input type', () => {
320+
type res1 = ExtractPreciseValue<
321+
{ a: string } | { b: {} },
322+
{ b: nonNullable }
323+
>;
324+
type test1 = Expect<Equal<res1, { b: {} }>>;
325+
});
326+
327+
it('should exclude objects even if the non-nullable key is deeply nested', () => {
328+
type res1 = ExtractPreciseValue<{ a: number }, { b: { c: nonNullable } }>;
329+
type test1 = Expect<Equal<res1, never>>;
330+
331+
type res2 = ExtractPreciseValue<
332+
| { nested: { a: string } }
333+
| { nested: { b: number } }
334+
| { nested: { b: string; c: boolean } },
335+
{ nested: { b: nonNullable } }
336+
>;
337+
type test2 = Expect<
338+
Equal<
339+
res2,
340+
{ nested: { b: number } } | { nested: { b: string; c: boolean } }
341+
>
342+
>;
343+
});
344+
});
345+
295346
describe('Branded strings', () => {
296347
it('Type narrowing should correctly work on branded strings', () => {
297348
// Branded strings is a commonly used way of implementing

tests/helpers.test.ts

-35
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
Drop,
33
Equal,
4-
ExcludeIfContainsNever,
54
Expect,
65
Iterator,
76
LeastUpperBound,
@@ -74,40 +73,6 @@ describe('helpers', () => {
7473
];
7574
});
7675

77-
describe('ExcludeIfContainsNever', () => {
78-
it('should work with objects and tuples', () => {
79-
type cases = [
80-
Expect<
81-
Equal<
82-
ExcludeIfContainsNever<
83-
{ kind: 'some'; value: string } | { kind: never },
84-
{ kind: 'some' }
85-
>,
86-
{ kind: 'some'; value: string }
87-
>
88-
>,
89-
Expect<
90-
Equal<
91-
ExcludeIfContainsNever<
92-
[{ kind: 'some'; value: string } | never],
93-
[{ kind: 'some' }]
94-
>,
95-
[{ kind: 'some'; value: string }]
96-
>
97-
>,
98-
Expect<
99-
Equal<
100-
ExcludeIfContainsNever<
101-
[{ kind: 'some'; value: string }, never],
102-
[{ kind: 'some' }, unknown]
103-
>,
104-
never
105-
>
106-
>
107-
];
108-
});
109-
});
110-
11176
describe('LeastUpperBound', () => {
11277
it('If both a and b extend each other, it should pick b', () => {
11378
class B {}

tests/type-error.test.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ describe('type errors', () => {
119119
it("if a pattern is any, the outer expression shouldn't throw a type error", () => {
120120
const anyVar = null as any;
121121

122-
match({ a: 'a' })
122+
const input = { a: 'a' };
123+
124+
match(input)
123125
.with({ a: anyVar }, (x) => {
124-
type t = Expect<Equal<typeof x, { a: never }>>;
126+
type t = Expect<Equal<typeof x, typeof input>>;
125127
return 'Ok';
126128
})
127129
.otherwise(() => 'ko');

tests/wildcards.test.ts

+56-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Expect, Equal } from '../src/types/helpers';
22
import { match, P } from '../src';
33
import { Blog } from './types-catalog/utils';
4+
import { InvertPattern } from '../src/types/InvertPattern';
5+
import { ExtractPreciseValue } from '../src/types/ExtractPreciseValue';
46

57
describe('wildcards', () => {
68
it('should match String wildcards', () => {
@@ -56,25 +58,61 @@ describe('wildcards', () => {
5658
expect(res2).toEqual(true);
5759
});
5860

59-
it('should match nonNullable wildcard', () => {
60-
type Input = string | number | boolean | null | undefined;
61-
const res = match<Input>(false)
62-
.with(P.nonNullable, (x) => {
63-
type t = Expect<Equal<typeof x, string | number | boolean>>;
64-
return true;
65-
})
66-
.otherwise(() => false);
67-
68-
const res2 = match<0 | 1 | 2 | null>(0)
69-
.with(P.nonNullable, (x) => {
70-
type t = Expect<Equal<typeof x, 0 | 1 | 2>>;
71-
return true;
72-
})
73-
.with(null, () => false)
74-
.exhaustive();
61+
describe('P.nonNullable', () => {
62+
it('should narrow primitive types correctly', () => {
63+
type Input = string | number | boolean | null | undefined;
64+
const res = match<Input>(false)
65+
.with(P.nonNullable, (x) => {
66+
type t = Expect<Equal<typeof x, string | number | boolean>>;
67+
return true;
68+
})
69+
.otherwise(() => false);
70+
71+
const res2 = match<0 | 1 | 2 | null>(0)
72+
.with(P.nonNullable, (x) => {
73+
type t = Expect<Equal<typeof x, 0 | 1 | 2>>;
74+
return true;
75+
})
76+
.with(null, () => false)
77+
.exhaustive();
78+
79+
expect(res).toEqual(true);
80+
expect(res2).toEqual(true);
81+
});
7582

76-
expect(res).toEqual(true);
77-
expect(res2).toEqual(true);
83+
it('should narrow object types correctly', () => {
84+
type Input =
85+
| {
86+
__typename: 'ValidationRejection';
87+
fields: string[];
88+
}
89+
| {
90+
__typename: 'ValidationRejection';
91+
};
92+
93+
const pattern = {
94+
__typename: 'ValidationRejection',
95+
fields: P.nonNullable,
96+
} as const;
97+
type X = InvertPattern<typeof pattern, Input>;
98+
type Y = ExtractPreciseValue<Input, X>;
99+
100+
const fn = (data: Input) =>
101+
match(data)
102+
.with(
103+
{ __typename: 'ValidationRejection', fields: P.nonNullable },
104+
({ fields }) => {
105+
type t = Expect<Equal<typeof fields, string[]>>;
106+
return 'matched';
107+
}
108+
)
109+
.otherwise(() => 'did not match');
110+
111+
expect(fn({ __typename: 'ValidationRejection' })).toBe('did not match');
112+
expect(fn({ __typename: 'ValidationRejection', fields: [] })).toBe(
113+
'matched'
114+
);
115+
});
78116
});
79117

80118
it('should match String, Number and Boolean wildcards', () => {

0 commit comments

Comments
 (0)