Exclusive Or and the Optional never Trick

Apologies for the long delay between posts. I've been busy getting married and climbing. With winter setting in and the wedding behind me, I hope that I'll find more time for writing.

"Do you want coffee or tea?"

"Yes"

In ordinary speech, "or" means "exclusive or." Only programmers and logicians use an inclusive or.

In TypeScript, it's easy to get mixed up between these two:

interface ThingOne {
shirtColor: string;
}
interface ThingTwo {
hairColor: string;
}
type Thing = ThingOne | ThingTwo;

We usually read the last line as "Type Thing is a ThingOne or ThingTwo." But just like JavaScript's runtime or (||), TypeScript's type-level or (|) is an inclusive or. There's no reason a thing can't be both a ThingOne and a ThingTwo:

const allThings: Thing = {
shirtColor: 'red',
hairColor: 'blue',
}; // ok

Why does this work? It's because TypeScript has a structural type system. Both the ThingOne and ThingTwo types allow additional properties that aren't declared in their interface (this fact is sometimes obscured by excess property checking):

const bothThings = {
shirtColor: 'red',
hairColor: 'blue',
};
const thing1: Thing1 = bothThings; // ok
const thing2: Thing2 = bothThings; // ok

So what if you really do want an exclusive or? What if you want to keep your ThingOnes and ThingTwos separate? How can you model that?

There's a standard trick, which is to use an optional never type in your interface to disallow a property:

interface OnlyThingOne {
shirtColor: string;
hairColor?: never;
}
interface OnlyThingTwo {
hairColor: string;
shirtColor?: never;
}
type ExclusiveThing = OnlyThingOne | OnlyThingTwo;

Now none of the assignments from before pass the type checker (see playground):

const thing1: OnlyThingOne = bothThings;
// ~~~~~~ Types of property 'hairColor' are incompatible.
const thing2: OnlyThingTwo = bothThings;
// ~~~~~~ Types of property 'shirtColor' are incompatible.
const allThings: ExclusiveThing = {
// ~~~~~~~~~ Types of property 'shirtColor' are incompatible.
shirtColor: 'red',
hairColor: 'blue',
};

This works because no value is assignable to a never type. But because the property is optional, there's exactly one way out: not having that property.

This isn't just useful for unions. If you want to define a two dimensional vector type, for example, you might want to specifically disallow adding a third dimension:

interface Vector2 {
x: number;
y: number;
z?: never;
}

With this type, you'll get an error if you accidentally pass a three-dimensional vector to a function like norm:

function norm(v: Vector2) {
return Math.sqrt(v.x ** 2 + v.y ** 2);
}
const v = {x: 3, y: 4, z: 5};
const d = norm(v);
// ~ Types of property 'z' are incompatible.

This wouldn't be an error without the z?: never because the call is structurally valid, even though it's semantically incorrect.

"Tags" are another common way to make an or exclusive:

interface ThingOne {
type: 'one';
shirtColor: string;
}
interface ThingTwo {
type: 'two';
hairColor: string;
}
type Thing = ThingOne | ThingTwo;

A string can't be both "one" and "two", so there's no overlap between these types. This means there's no distinction between inclusive and exclusive or. This is one of many great reasons to use tagged unions when you can.

It's a fun exercise to define an exclusive or generic:

type XOR<A, B> = /* ??? */;

Give it a try!

Here's what I came up with:

type XOR<T1, T2> =
(T1 & {[k in Exclude<keyof T2, keyof T1>]?: never}) |
(T2 & {[k in Exclude<keyof T1, keyof T2>]?: never});

This is roughly the same as the XOR implementation from the ts-essentials library.

There's one caveat to the optional never trick that's worth knowing: unless you set the --exactOptionalPropertyTypes compiler flag (added in TS 4.4), you're allowed to assign undefined to an optional never field:

interface Vector2 {
x: number;
y: number;
z?: never;
}

// OK with just --strict
const v: Vector2 = {x: 1, y: 2, z: undefined};

// Error with --exactOptionalPropertyTypes
const w: Vector2 = {x: 1, y: 2, z: undefined};
// ~
// Type 'undefined' is not assignable to type 'never'.

So remember: in TypeScript, "or" is a union: A | B means either A, B, or both. Remember that "both" is a possibility, and you should either prevent it or handle it in your code.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »