Typescript XOR puzzle: the union operator
The Problem
At work, one of my colleagues gave us a puzzle to solve:
type localOnly = { local: number } type remoteOnly = { remote: number } type localAndRemote = localOnly & remoteOnly // Examples of valid objects with these types const a: localOnly = { local: 2 } const b: remoteOnly = { remote: 2 } const c: localAndRemote = { ...a, ...b } // Shouldn't be OK, remote should either be a number or not defined at all. const d: localOnly | localAndRemote = { ...a, remote: undefined }
He was expecting that for the type
localOnly | localAndRemote
, remote must be a number
because the type is either { local: number }
or { local: number; remote: number }
. { local: number; remote: undefined }
is neither of these (having a key with a value of undefined
is not the same as having no value for that key - Object.keys()
would return different things in both instances!).The Solution
The union operator (
|
) is designed to work this way (we might think of this as an OR because of the asociations with logical operators ||
).Let’s simplify the problem and create some examples to see what works:
type UnionType = { a: number } | { a: number; b: number } const _0: UnionType = { a: 0 }; // ✅ works const _1: UnionType = { a: 0, b: undefined }; // ✅ works const _2: UnionType = { a: 0, b: 0 }; // ✅ works const _3: UnionType = { a: 0, b: NaN }; // ✅ works (NaN is actually a number) const _4: UnionType = { a: undefined, b: 0 }; // ❌ Type 'undefined' is not assignable to type 'number'. const _5: UnionType = { a: 0, b: "string" }; // ❌ Type 'string' is not assignable to type 'number'. const _6: UnionType = { a: 0, b: true }; // ❌ Type 'boolean' is not assignable to type 'number'. const _7: UnionType = { a: 0, b: null }; // ❌ Type 'null' is not assignable to type 'number'. const _8: UnionType = { a: 0, c: undefined }; // ❌ Type '{ a: number; c: undefined; }' is not assignable to type '{ a: number; } | { a: number; b: number; }'.
Make sure you have
strictNullChecks
enabled in your tsconfig.json
or else null
will display the same behaviour here as undefined
.a
is always a required property because it is shared by both sides of the union and a
must be a number
. We see that where there is a numeric a
property, the type check passes in two cases b: number
and b: undefined
, all other cases give the “expected” behaviour.It turns out
undefined
is a subtype of all other types (as of 2.0 docs!), which means that undefined
can be assigned to a number
.type UnionType<T> = { a: number } | { a: number; b: T } const _0: UnionType<number> = { a:0, b: undefined }; // ✅ works const _1: UnionType<string> = { a:0, b: undefined }; // ✅ works const _2: UnionType<boolean> = { a:0, b: undefined }; // ✅ works const _3: UnionType<null> = { a:0, b: undefined }; // ✅ works const _4: UnionType<never> = { a:0, b: undefined }; // ✅ works const _5: UnionType<{}> = { a:0, b: undefined }; // ✅ works
Another thing that may accidentally go wrong in unions is the
{}
type, which looks like it means “empty record”, but actually means “object with any properties”. const _0: { a: string } | {} = { a:0 }; // ⚠️⚠️ works
.
To enforce an empty record, use Record<string, never>
:
const forcedEmpty: Record<string, never> = { a: '', 0: '', true: ''}; // ✅ throws an error
For his purpose, he was looking for an XOR operator in TypeScript which circumvents this property of
undefined
. I got some example code from this library.The Code
To get the desired effect (preventing matching properties being set to
undefined
):type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never; }; type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; type localOnly = { local: number } type remoteOnly = { remote: number } type localAndRemote = localOnly & remoteOnly const a: localOnly = { local: 2 }; const b: remoteOnly = { remote: 2 }; const c: localAndRemote = { ...a, ...b } const d: XOR<localAndRemote, localOnly> = { ...a, remote: undefined }; ^ // Types of property 'remote' are incompatible. // Type 'undefined' is not assignable to type 'never'.(2375)