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)