Skip to content

Commit 2b906fe

Browse files
authored
Add ExclusifyUnion type (#1278)
1 parent ce2d244 commit 2b906fe

File tree

5 files changed

+206
-0
lines changed

5 files changed

+206
-0
lines changed

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export type {IsUppercase} from './source/is-uppercase.d.ts';
163163
export type {IsOptional} from './source/is-optional.d.ts';
164164
export type {IsNullable} from './source/is-nullable.d.ts';
165165
export type {TupleOf} from './source/tuple-of.d.ts';
166+
export type {ExclusifyUnion} from './source/exclusify-union.d.ts';
166167

167168
// Template literal types
168169
export type {CamelCase, CamelCaseOptions} from './source/camel-case.d.ts';

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ Click the type names for complete docs.
188188
- [`FindGlobalInstanceType`](source/find-global-type.d.ts) - Tries to find one or more types from their globally-defined constructors.
189189
- [`ConditionalSimplify`](source/conditional-simplify.d.ts) - Simplifies a type while including and/or excluding certain types from being simplified.
190190
- [`ConditionalSimplifyDeep`](source/conditional-simplify-deep.d.ts) - Recursively simplifies a type while including and/or excluding certain types from being simplified.
191+
- [`ExclusifyUnion`](source/exclusify-union.d.ts) - Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`.
191192

192193
### Type Guard
193194

source/exclusify-union.d.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type {If} from './if.d.ts';
2+
import type {IfNotAnyOrNever, MapsSetsOrArrays, NonRecursiveType} from './internal/type.d.ts';
3+
import type {IsUnknown} from './is-unknown.d.ts';
4+
import type {KeysOfUnion} from './keys-of-union.d.ts';
5+
import type {Simplify} from './simplify.d.ts';
6+
7+
/**
8+
Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`.
9+
10+
Use-cases:
11+
- You want each union member to be exclusive, preventing overlapping object shapes.
12+
- You want to safely access any property defined across the union without additional type guards.
13+
14+
@example
15+
```
16+
import type {ExclusifyUnion} from 'type-fest';
17+
18+
type FileConfig = {
19+
filePath: string;
20+
};
21+
22+
type InlineConfig = {
23+
content: string;
24+
};
25+
26+
declare function loadConfig1(options: FileConfig | InlineConfig): void;
27+
28+
// Someone could mistakenly provide both `filePath` and `content`.
29+
loadConfig1({filePath: './config.json', content: '{ "name": "app" }'}); // No errors
30+
31+
// Use `ExclusifyUnion` to prevent that mistake.
32+
type Config = ExclusifyUnion<FileConfig | InlineConfig>;
33+
//=> {filePath: string; content?: never} | {content: string; filePath?: never}
34+
35+
declare function loadConfig2(options: Config): void;
36+
37+
// @ts-expect-error
38+
loadConfig2({filePath: './config.json', content: '{ "name": "app" }'});
39+
//=> Error: Argument of type '{ filePath: string; content: string; }' is not assignable to parameter of type '{ filePath: string; content?: never; } | { content: string; filePath?: never; }'.
40+
41+
loadConfig2({filePath: './config.json'}); // Ok
42+
43+
loadConfig2({content: '{ "name": "app" }'}); // Ok
44+
```
45+
46+
@example
47+
```
48+
import type {ExclusifyUnion} from 'type-fest';
49+
50+
type CardPayment = {
51+
amount: number;
52+
cardNumber: string;
53+
};
54+
55+
type PaypalPayment = {
56+
amount: number;
57+
paypalId: string;
58+
};
59+
60+
function processPayment1(payment: CardPayment | PaypalPayment) {
61+
// @ts-expect-error
62+
const details = payment.cardNumber ?? payment.paypalId; // Cannot access `cardNumber` or `paypalId` directly
63+
}
64+
65+
type Payment = ExclusifyUnion<CardPayment | PaypalPayment>;
66+
//=> {amount: number; cardNumber: string; paypalId?: never} | {amount: number; paypalId: string; cardNumber?: never}
67+
68+
function processPayment2(payment: Payment) {
69+
const details = payment.cardNumber ?? payment.paypalId; // Ok
70+
//=> string
71+
}
72+
```
73+
74+
@example
75+
```
76+
import type {ExclusifyUnion} from 'type-fest';
77+
78+
type A = ExclusifyUnion<{a: string} | {b: number}>;
79+
//=> {a: string; b?: never} | {a?: never; b: number}
80+
81+
type B = ExclusifyUnion<{a: string} | {b: number} | {c: boolean}>;
82+
//=> {a: string; b?: never; c?: never} | {a?: never; b: number; c?: never} | {a?: never; b?: never; c: boolean}
83+
84+
type C = ExclusifyUnion<{a: string; b: number} | {b: string; c: number}>;
85+
//=> {a: string; b: number; c?: never} | {a?: never; b: string; c: number}
86+
87+
type D = ExclusifyUnion<{a?: 1; readonly b: 2} | {d: 4}>;
88+
//=> {a?: 1; readonly b: 2; d?: never} | {a?: never; b?: never; d: 4}
89+
```
90+
91+
@category Object
92+
@category Union
93+
*/
94+
export type ExclusifyUnion<Union> = IfNotAnyOrNever<Union,
95+
If<IsUnknown<Union>, Union,
96+
Extract<Union, NonRecursiveType | MapsSetsOrArrays> extends infer SkippedMembers
97+
? SkippedMembers | _ExclusifyUnion<Exclude<Union, SkippedMembers>>
98+
: never
99+
>
100+
>;
101+
102+
type _ExclusifyUnion<Union, UnionCopy = Union> = Union extends unknown // For distributing `Union`
103+
? Simplify<
104+
Union & Partial<
105+
Record<
106+
Exclude<KeysOfUnion<UnionCopy>, keyof Union>,
107+
never
108+
>
109+
>
110+
>
111+
: never; // Should never happen
112+
113+
export {};

source/internal/type.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {If} from '../if.d.ts';
22
import type {IsAny} from '../is-any.d.ts';
33
import type {IsNever} from '../is-never.d.ts';
44
import type {Primitive} from '../primitive.d.ts';
5+
import type {UnknownArray} from '../unknown-array.d.ts';
56

67
/**
78
Matches any primitive, `void`, `Date`, or `RegExp` value.
@@ -13,6 +14,11 @@ Matches non-recursive types.
1314
*/
1415
export type NonRecursiveType = BuiltIns | Function | (new (...arguments_: any[]) => unknown);
1516

17+
/**
18+
Matches maps, sets, or arrays.
19+
*/
20+
export type MapsSetsOrArrays = ReadonlyMap<unknown, unknown> | WeakMap<WeakKey, unknown> | ReadonlySet<unknown> | WeakSet<WeakKey> | UnknownArray;
21+
1622
/**
1723
Returns a boolean for whether the two given types extends the base type.
1824
*/

test-d/exclusify-union.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {expectType} from 'tsd';
2+
import type {ExclusifyUnion} from '../source/exclusify-union.d.ts';
3+
import type {MapsSetsOrArrays, NonRecursiveType} from '../source/internal/type.d.ts';
4+
5+
expectType<{a: string; b?: never} | {a?: never; b: number}>({} as ExclusifyUnion<{a: string} | {b: number}>);
6+
expectType<{a: string; b?: never; c?: never} | {a?: never; b: number; c?: never} | {a?: never; b?: never; c: boolean}>(
7+
{} as ExclusifyUnion<{a: string} | {b: number} | {c: boolean}>,
8+
);
9+
expectType<{a: string; b: number; c?: never; d?: never} | {a?: never; b?: never; c: string; d: number}>(
10+
{} as ExclusifyUnion<{a: string; b: number} | {c: string; d: number}>,
11+
);
12+
expectType<
13+
| {a: string; b?: never; c?: never; d?: never; e?: never; f?: never}
14+
| {a?: never; b: string; c: number; d?: never; e?: never; f?: never}
15+
| {a?: never; b?: never; c?: never; d: 1; e: 2; f: 3}
16+
>(
17+
{} as ExclusifyUnion<{a: string} | {b: string; c: number} | {d: 1; e: 2; f: 3}>,
18+
);
19+
20+
// Single member union
21+
expectType<{a: string}>({} as ExclusifyUnion<{a: string}>);
22+
expectType<{a?: string; readonly b?: number}>({} as ExclusifyUnion<{a?: string; readonly b?: number}>);
23+
24+
// Shared keys
25+
expectType<{a: string; b?: never} | {a: string; b: number}>({} as ExclusifyUnion<{a: string} | {a: string; b: number}>);
26+
expectType<{a: string; b?: never; c?: never} | {a: string; b: number; c?: never} | {a?: never; b: string; c: boolean}>(
27+
{} as ExclusifyUnion<{a: string} | {a: string; b: number} | {b: string; c: boolean}>,
28+
);
29+
30+
// Already exclusive unions
31+
expectType<{a: string; b?: never} | {a?: never; b: number}>({} as ExclusifyUnion<{a: string; b?: never} | {a?: never; b: number}>);
32+
expectType<{a: string} | {a: number}>({} as ExclusifyUnion<{a: string} | {a: number}>);
33+
34+
// Preserves property modifiers
35+
expectType<{a?: 1; readonly b: 2; readonly c?: 3; d?: never; e?: never} | {a?: never; b?: never; c?: never; d: 4; readonly e?: 5}>(
36+
{} as ExclusifyUnion<{a?: 1; readonly b: 2; readonly c?: 3} | {d: 4; readonly e?: 5}>,
37+
);
38+
expectType<{a?: string; readonly b: number} | {readonly a: string; b?: number}>(
39+
{} as ExclusifyUnion<{a?: string; readonly b: number} | {readonly a: string; b?: number}>,
40+
);
41+
42+
// Non-recursive types
43+
expectType<Set<string> | Map<string, string>>({} as ExclusifyUnion<Set<string> | Map<string, string>>);
44+
expectType<WeakSet<{a: string}> | WeakMap<{a: string}, string>>({} as ExclusifyUnion<WeakSet<{a: string}> | WeakMap<{a: string}, string>>);
45+
expectType<string[] | Set<string>>({} as ExclusifyUnion<string[] | Set<string>>);
46+
expectType<NonRecursiveType>({} as ExclusifyUnion<NonRecursiveType>);
47+
expectType<MapsSetsOrArrays>({} as ExclusifyUnion<MapsSetsOrArrays>);
48+
49+
// Mix of non-recursive and recursive types
50+
expectType<{a: string; b?: never} | {a: number; b: true} | undefined>({} as ExclusifyUnion<{a: string} | {a: number; b: true} | undefined>);
51+
expectType<Date | {DDMMYYYY: string; MMDDYYYY?: never} | {DDMMYYYY?: never; MMDDYYYY: string}>(
52+
{} as ExclusifyUnion<Date | {DDMMYYYY: string} | {MMDDYYYY: string}>,
53+
);
54+
expectType<RegExp | null | {foo: string; bar?: never; baz?: never} | {foo?: never; bar: number; baz: {qux: string}}>(
55+
{} as ExclusifyUnion<RegExp | null | {foo: string} | {bar: number; baz: {qux: string}}>,
56+
);
57+
58+
// Practical test cases
59+
type FileConfig = {filePath: string};
60+
type InlineConfig = {content: string};
61+
62+
type Config = ExclusifyUnion<FileConfig | InlineConfig>;
63+
//=> {filePath: string; content?: never} | {content: string; filePath?: never}
64+
65+
declare function loadConfig(options: Config): void;
66+
67+
// @ts-expect-error
68+
loadConfig({filePath: './config.json', content: '{ "name": "app" }'}); // Cannot provide both properties
69+
loadConfig({filePath: './config.json'}); // Ok
70+
loadConfig({content: '{ "name": "app" }'}); // Ok
71+
72+
type CardPayment = {amount: number; cardNumber: string};
73+
type PaypalPayment = {amount: number; paypalId: string};
74+
75+
function processPayment(payment: ExclusifyUnion<CardPayment | PaypalPayment>) {
76+
// Can access `cardNumber` or `paypalId` directly
77+
// And, the resulting type is also correctly `string`
78+
const details = payment.cardNumber ?? payment.paypalId;
79+
expectType<string>(details);
80+
}
81+
82+
// Boundary types
83+
expectType<unknown>({} as ExclusifyUnion<unknown>);
84+
expectType<any>({} as ExclusifyUnion<any>);
85+
expectType<never>({} as ExclusifyUnion<never>);

0 commit comments

Comments
 (0)