TypeScript object types aren't sealed or exact (as requested in microsoft/TypeScript#12936). Just because a type doesn't mention a property, it doesn't prohibit the property. So technically speaking a value of type {role: "regular"}
might have a property called force
of some completely unknown type:
type A = { role: 'admin', force?: boolean };
type B = { role: 'regular' };
type Or = A | B;
const hmm = { role: 'regular', force: 9000 } as const;
const oops: Or = hmm; // <-- accepted
And by that logic is not safe for you to do what you're trying to do. The only safe destructuring of a value of type Or
into its pieces would make the force
property of type unknown
.
But from here forward, let's assume that issue is not going to arise, and that we assert that the value only has properties known by the compiler, and any properties not mentioned in the type will be of type undefined
.
How then can we "fix" a union type like Or
to be a version that explicitly knows about all keys from the other members so that the compiler lets you index into it with any of these keys?
Here's one possible way:
type FixUnion<T, K extends PropertyKey = T extends unknown ? keyof T : never> =
T extends unknown ? (
T & { [P in Exclude<K, keyof T>]?: never } extends infer O ? {
[P in keyof O]: O[P]
} : never
) : never
First let's make sure it works on Or
:
type FixedOr = FixUnion<Or>;
/* type FixedOr = {
role: 'admin';
force?: boolean | undefined;
} | {
role: 'regular';
force?: undefined;
} */
That looks correct. Both union members have a role
and a force
property. The first member is the same as A
, while the second member is B
with an additional optional property of type undefined
. So now you're allowed to destructure it:
const { role, force } = or as FixUnion<Or>;
// const role: "admin" | "regular"
// const force: boolean | undefined
So how does it work? First let's look at the K
type parameter. I'm actually just using a default value to compute all the keys of every member of the T
union. The type T extends unknown ? keyof T : never
is a distributive conditional type which breaks T
into its union members, grabs their individual keys, and unites them back together into a new union. I sometimes call this operation AllKeys<T>
as shown in Is it possible to get the keys from a union of objects? . For Or
, K
will be "role" | "force"
.
So now K
is all the keys. The body of FixUnion<T>
is another distributive conditional type; for each member of the T
union, we intersect it with { [P in Exclude<K, keyof T>]?: never }
. That's a mapped type with all optional keys (hence the ?
) which are present in K
but not in the current union member (using the Exclude<T, U>
utility type). And the property values are never
, the impossible type. For Or
, the first member will look like A & {}
and the second member will look like B & { force?: never }
. Note that optional properties automatically get undefined
in their domain, so it's the same as B & { force?: undefined }
. The point is that the force
property in the second union member is known to be either missing or undefined
.
Finally, in order to make things prettier, I use a technique to turn intersections like A & {}
into a plain object like {role: "admin", force?: boolean}
, as described in How can I see the full expanded contract of a Typescript type? ; the ... extends infer O ? {[P in keyof O]: O[P]} : never}
does that. So then the first member becomes {role: "admin", force?: boolean}
, and the second becomes {role: "regular", force?: undefined}
.
Let's just test it out on a different union, just to see how it works:
type Foo = FixUnion<{ a: 0 } | { b: 1 } | { a: 2 } | { c: 3 }>;
/* type Foo = {
a: 0;
b?: undefined;
c?: undefined;
} | {
b: 1;
a?: undefined;
c?: undefined;
} | {
a: 2;
b?: undefined;
c?: undefined;
} | {
c: 3;
a?: undefined;
b?: undefined;
} */
All four union members have all three keys, but at least two keys are optional keys of type undefined
.
Playground link to code