A slightly modified version of StrictUnion
as detailed in the post here seems to be effective:
type UnionKeys<T> = T extends T? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends T? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, undefined>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>
function example(props: StrictUnion<{ id: number } & ({ name: string } | { age: number })>) {
const { id, name, age } = props
}
Playground Link
The concept behind StrictUnion
is to ensure all elements within a union have every member from each element. This is accomplished by filling in missing members with type undefined
. Thus, a type like
{ id: number } & ({ name: string } | { age: number })
transforms into
{ id: number; name: string; age: undefined }
or
{ id: number; name: undefined; age: number }
, allowing for easy de-structuring.
To create StrictUnion
, we first collect keys from all union constituents using conditional types' distribution behavior. By leveraging an always true condition (such as T extends T
, T extends unknown
, or less favorably, T extends any
), we can compile a type that extracts keys from each constituent and consolidates them. The key extraction process looks like this:
type UnionKeys<T> = T extends T ? keyof T : never;
This application of the type is exemplified below:
type A = { id: number; name: string }
type B = { id: number; age: number }
UnionKeys<A | B>
// Conditional type applied to A and B and the results combined
<=> (A extends unknown ? keyof A: never) | (B extends unknown ? keyof B: never)
<=> keyof A | keyof B
<=> ("id" | "name") | ("id" | "age")
<=> "id" | "name" | "age"
Once we have UnionKeys
, another distributive conditional type inspects each union member to identify missing keys from a given type T
(using
Exclude<UnionKeys<TAll>, keyof T>
). This process entails intersecting the original
T
with a
Partial
Record
featuring these absent keys typed as
undefined
. The union must be supplied twice: once for distribution (
T
) and again to gather all keys through
UnionKeys
.
Below is how this type is utilized:
type A = { id: number; name: string }
type B = { id: number; age: number }
StrictUnion<A | B>
<=> StrictUnionHelper <A | B, A | B>
// Distributes over T
<=> (A extends A ? A & Partial<Record<Exclude<UnionKeys<A | B>, keyof A>, undefined>> : never) | (B extends B ? B & Partial<Record<Exclude<UnionKeys<A | B>, keyof B>, undefined>>> : never)
<=> (A extends A ? A & Partial<Record<Exclude<"id" | "name" | "age", "id" | "name">, undefined>> : never) | (B extends B ? B & Partial<Record<Exclude<"id" | "name" | "age", "id" | "age">, undefined>>> : never)
<=> (A extends A ? A & Partial<Record<"age", undefined>> : never) | (B extends B ? B & Partial < Record < "name" >, undefined >>> : never)
// With both conditions evaluating to true, the intersection is carried out successfully
<=> { id: number; name: string; age?: undefined } | { id: number; age: number; name?: undefined }