Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export type {IsUppercase} from './source/is-uppercase.d.ts';
export type {IsOptional} from './source/is-optional.d.ts';
export type {IsNullable} from './source/is-nullable.d.ts';
export type {TupleOf} from './source/tuple-of.d.ts';
export type {ExclusifyUnion} from './source/exclusify-union.d.ts';

// Template literal types
export type {CamelCase, CamelCaseOptions} from './source/camel-case.d.ts';
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Click the type names for complete docs.
- [`FindGlobalInstanceType`](source/find-global-type.d.ts) - Tries to find one or more types from their globally-defined constructors.
- [`ConditionalSimplify`](source/conditional-simplify.d.ts) - Simplifies a type while including and/or excluding certain types from being simplified.
- [`ConditionalSimplifyDeep`](source/conditional-simplify-deep.d.ts) - Recursively simplifies a type while including and/or excluding certain types from being simplified.
- [`ExclusifyUnion`](source/exclusify-union.d.ts) - Add all missing properties from other union members as optional `never` to make each union member mutually exclusive.

### Type Guard

Expand Down
109 changes: 109 additions & 0 deletions source/exclusify-union.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type {If} from './if.d.ts';
import type {IfNotAnyOrNever, NonRecursiveType} from './internal/type.d.ts';
import type {IsUnknown} from './is-unknown.d.ts';
import type {KeysOfUnion} from './keys-of-union.d.ts';
import type {Simplify} from './simplify.d.ts';
import type {UnknownArray} from './unknown-array.d.ts';

/**
Add all missing properties from other union members as optional `never` to make each union member mutually exclusive.

Use-cases:
- You want each union member to be exclusive, preventing overlapping object shapes.
- You want to safely access any property defined across the union without additional type guards.

@example
```
import type {ExclusifyUnion} from 'type-fest';

type FileConfig = {
filePath: string;
};

type InlineConfig = {
content: string;
};

declare function loadConfig1(options: FileConfig | InlineConfig): void;

// Someone could mistakenly provide both `filePath` and `content`.
loadConfig1({filePath: './config.json', content: '{ "name": "app" }'}); // No errors

// Use `ExclusifyUnion` to prevent that mistake.
type Config = ExclusifyUnion<FileConfig | InlineConfig>;
//=> {filePath: string; content?: never} | {content: string; filePath?: never}

declare function loadConfig2(options: Config): void;

// @ts-expect-error
loadConfig2({filePath: './config.json', content: '{ "name": "app" }'});
//=> Error: Argument of type '{ filePath: string; content: string; }' is not assignable to parameter of type '{ filePath: string; content?: never; } | { content: string; filePath?: never; }'.

loadConfig2({filePath: './config.json'}); // Ok

loadConfig2({content: '{ "name": "app" }'}); // Ok
```

@example
```
import type {ExclusifyUnion} from 'type-fest';

type CardPayment = {
amount: number;
cardNumber: string;
};

type PaypalPayment = {
amount: number;
paypalId: string;
};

function processPayment1(payment: CardPayment | PaypalPayment) {
// @ts-expect-error
const details = payment.cardNumber ?? payment.paypalId; // Cannot access `cardNumber` or `paypalId` directly
}

type Payment = ExclusifyUnion<CardPayment | PaypalPayment>;
//=> {amount: number; cardNumber: string; paypalId?: never} | {amount: number; paypalId: string; cardNumber?: never}

function processPayment2(payment: Payment) {
const details = payment.cardNumber ?? payment.paypalId; // Ok
//=> string
}
```

@example
```
import type {ExclusifyUnion} from 'type-fest';

type A = ExclusifyUnion<{a: string} | {b: number}>;
//=> {a: string; b?: never} | {a?: never; b: number}

type B = ExclusifyUnion<{a: string} | {b: number} | {c: boolean}>;
//=> {a: string; b?: never; c?: never} | {a?: never; b: number; c?: never} | {a?: never; b?: never; c: boolean}

type C = ExclusifyUnion<{a: string; b: number} | {b: string; c: number}>;
//=> {a: string; b: number; c?: never} | {a?: never; b: string; c: number}

type D = ExclusifyUnion<{a?: 1; readonly b: 2} | {d: 4}>;
//=> {a?: 1; readonly b: 2; d?: never} | {a?: never; b?: never; d: 4}
```

@category Object
@category Union
*/
export type ExclusifyUnion<Union> = IfNotAnyOrNever<Union,
If<IsUnknown<Union>, Union,
Extract<Union, NonRecursiveType | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> | UnknownArray> extends infer SkippedMembers
? SkippedMembers | _ExclusifyUnion<Exclude<Union, SkippedMembers>>
: never
>
>;

type _ExclusifyUnion<Union, UnionCopy = Union> = Union extends unknown // For distributing `Union`
? Simplify<
Union & Partial<Record<Exclude<KeysOfUnion<UnionCopy>, keyof Union>, never>>
>
: never; // Should never happen

export {};
59 changes: 59 additions & 0 deletions test-d/exclusify-union.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {expectType} from 'tsd';
import type {ExclusifyUnion} from '../source/exclusify-union.d.ts';
import type {NonRecursiveType} from '../source/internal/type.d.ts';

expectType<{a: string; b?: never} | {a?: never; b: number}>({} as ExclusifyUnion<{a: string} | {b: number}>);
expectType<{a: string; b?: never; c?: never} | {a?: never; b: number; c?: never} | {a?: never; b?: never; c: boolean}>(
{} as ExclusifyUnion<{a: string} | {b: number} | {c: boolean}>,
);
expectType<{a: string; b: number; c?: never; d?: never} | {a?: never; b?: never; c: string; d: number}>(
{} as ExclusifyUnion<{a: string; b: number} | {c: string; d: number}>,
);
expectType<
| {a: string; b?: never; c?: never; d?: never; e?: never; f?: never}
| {a?: never; b: string; c: number; d?: never; e?: never; f?: never}
| {a?: never; b?: never; c?: never; d: 1; e: 2; f: 3}
>(
{} as ExclusifyUnion<{a: string} | {b: string; c: number} | {d: 1; e: 2; f: 3}>,
);

// Single member union
expectType<{a: string}>({} as ExclusifyUnion<{a: string}>);
expectType<{a?: string; readonly b?: number}>({} as ExclusifyUnion<{a?: string; readonly b?: number}>);

// Shared keys
expectType<{a: string; b?: never} | {a: string; b: number}>({} as ExclusifyUnion<{a: string} | {a: string; b: number}>);
expectType<{a: string; b?: never; c?: never} | {a: string; b: number; c?: never} | {a?: never; b: string; c: boolean}>(
{} as ExclusifyUnion<{a: string} | {a: string; b: number} | {b: string; c: boolean}>,
);

// Already exclusive unions
expectType<{a: string; b?: never} | {a?: never; b: number}>({} as ExclusifyUnion<{a: string; b?: never} | {a?: never; b: number}>);
expectType<{a: string} | {a: number}>({} as ExclusifyUnion<{a: string} | {a: number}>);

// Preserves property modifiers
expectType<{a?: 1; readonly b: 2; readonly c?: 3; d?: never; e?: never} | {a?: never; b?: never; c?: never; d: 4; readonly e?: 5}>(
{} as ExclusifyUnion<{a?: 1; readonly b: 2; readonly c?: 3} | {d: 4; readonly e?: 5}>,
);
expectType<{a?: string; readonly b: number} | {readonly a: string; b?: number}>(
{} as ExclusifyUnion<{a?: string; readonly b: number} | {readonly a: string; b?: number}>,
);

// Non-recursive types
expectType<Set<string> | Map<string, string>>({} as ExclusifyUnion<Set<string> | Map<string, string>>);
expectType<string[] | Set<string>>({} as ExclusifyUnion<string[] | Set<string>>);
expectType<NonRecursiveType>({} as ExclusifyUnion<NonRecursiveType>);

// Mix of non-recursive and recursive types
expectType<{a: string; b?: never} | {a: number; b: true} | undefined>({} as ExclusifyUnion<{a: string} | {a: number; b: true} | undefined>);
expectType<Date | {DDMMYYYY: string; MMDDYYYY?: never} | {DDMMYYYY?: never; MMDDYYYY: string}>(
{} as ExclusifyUnion<Date | {DDMMYYYY: string} | {MMDDYYYY: string}>,
);
expectType<RegExp | null | {foo: string; bar?: never; baz?: never} | {foo?: never; bar: number; baz: {qux: string}}>(
{} as ExclusifyUnion<RegExp | null | {foo: string} | {bar: number; baz: {qux: string}}>,
);

// Boundary types
expectType<unknown>({} as ExclusifyUnion<unknown>);
expectType<any>({} as ExclusifyUnion<any>);
expectType<never>({} as ExclusifyUnion<never>);