diff --git a/index.d.ts b/index.d.ts index 973734adb..024e13b90 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; diff --git a/readme.md b/readme.md index ca0d94f24..d93fb22ac 100644 --- a/readme.md +++ b/readme.md @@ -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) - Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`. ### Type Guard diff --git a/source/exclusify-union.d.ts b/source/exclusify-union.d.ts new file mode 100644 index 000000000..6d90c4972 --- /dev/null +++ b/source/exclusify-union.d.ts @@ -0,0 +1,113 @@ +import type {If} from './if.d.ts'; +import type {IfNotAnyOrNever, MapsSetsOrArrays, 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'; + +/** +Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`. + +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; +//=> {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; +//=> {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 = IfNotAnyOrNever, Union, + Extract extends infer SkippedMembers + ? SkippedMembers | _ExclusifyUnion> + : never + > +>; + +type _ExclusifyUnion = Union extends unknown // For distributing `Union` + ? Simplify< + Union & Partial< + Record< + Exclude, keyof Union>, + never + > + > + > + : never; // Should never happen + +export {}; diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 035cdd489..d1ede8878 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -2,6 +2,7 @@ import type {If} from '../if.d.ts'; import type {IsAny} from '../is-any.d.ts'; import type {IsNever} from '../is-never.d.ts'; import type {Primitive} from '../primitive.d.ts'; +import type {UnknownArray} from '../unknown-array.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -13,6 +14,11 @@ Matches non-recursive types. */ export type NonRecursiveType = BuiltIns | Function | (new (...arguments_: any[]) => unknown); +/** +Matches maps, sets, or arrays. +*/ +export type MapsSetsOrArrays = ReadonlyMap | WeakMap | ReadonlySet | WeakSet | UnknownArray; + /** Returns a boolean for whether the two given types extends the base type. */ diff --git a/test-d/exclusify-union.ts b/test-d/exclusify-union.ts new file mode 100644 index 000000000..22b26fe9d --- /dev/null +++ b/test-d/exclusify-union.ts @@ -0,0 +1,85 @@ +import {expectType} from 'tsd'; +import type {ExclusifyUnion} from '../source/exclusify-union.d.ts'; +import type {MapsSetsOrArrays, 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 | Map>({} as ExclusifyUnion | Map>); +expectType | WeakMap<{a: string}, string>>({} as ExclusifyUnion | WeakMap<{a: string}, string>>); +expectType>({} as ExclusifyUnion>); +expectType({} as ExclusifyUnion); +expectType({} as ExclusifyUnion); + +// 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( + {} as ExclusifyUnion, +); +expectType( + {} as ExclusifyUnion, +); + +// Practical test cases +type FileConfig = {filePath: string}; +type InlineConfig = {content: string}; + +type Config = ExclusifyUnion; +//=> {filePath: string; content?: never} | {content: string; filePath?: never} + +declare function loadConfig(options: Config): void; + +// @ts-expect-error +loadConfig({filePath: './config.json', content: '{ "name": "app" }'}); // Cannot provide both properties +loadConfig({filePath: './config.json'}); // Ok +loadConfig({content: '{ "name": "app" }'}); // Ok + +type CardPayment = {amount: number; cardNumber: string}; +type PaypalPayment = {amount: number; paypalId: string}; + +function processPayment(payment: ExclusifyUnion) { + // Can access `cardNumber` or `paypalId` directly + // And, the resulting type is also correctly `string` + const details = payment.cardNumber ?? payment.paypalId; + expectType(details); +} + +// Boundary types +expectType({} as ExclusifyUnion); +expectType({} as ExclusifyUnion); +expectType({} as ExclusifyUnion);