Derek Lawless

There is always sunshine. Far above the grey sky

Union Types in TypeScript

17th March 2020

3 min read

Consider the following contrived lunchbox example:

class Apple {
	eat() {
		console.log("Eating an apple.");
	}
}
class Sandwich {
	eat() {
		console.log("Eating a sandwich.");
	}
}
class ChocolateBar {
	eat() {
		console.log("Eating a chocolate bar.");
	}
}

const lunchbox: any[] = [];

const addToLunchbox = (item: any): void => {
	lunchbox.push(item);
}

const getFromLunchbox = (): any => {
	return lunchbox.pop(); // Treat the lunchbox as a stack for simplicity
}

In the example above, addToLunchbox() accepts an item: any, allowing a caller greater flexibility when adding items to the lunchbox:

addToLunchbox(new Apple());
getFromLunchbox().eat(); // "Eating an apple."

addToLunchbox(new Sandwich());
getFromLunchbox().eat(); // "Eating a sandwich."

addToLunchbox(new ChocolateBar());
getFromLunchbox().eat(); // "Eating a chocolate bar."

However, the loose typing also allows for unintended use:

class Brick {}

addToLunchbox(new Brick());
getFromLunchbox().eat(); // Error!

You can of course tighten up contracts by introducing a base class, say, Food:

abstract class Food {
	abstract eat(): void
}

class Apple extends Food { ...}

const addToLunchbox = (food: Food): void => { ... }
const getFromLunchbox = (): Food => { ... }
}

As long as Brick doesn’t extend Food the problem appears to be solved. However, there will be cases where the strategy breaks down e.g. we want to allow liquid to be added:

class Water extends Food { // Really?
	drink() {
		console.log("Drinking water.");
	}
}

While it’s obviously incorrect for Water to extend Food, the temptation is real in order to quickly dig yourself out of a hole vs. having to modify the existing function contracts. Alternatively, you may consider replacing Food with a more generic base class providing both eat() and drink() methods.

Both solutions are poor in terms of program design, understandability, testability, and numerous other quality metrics.

A better way to solve this problem is to use union types. A union type describes a value that can be one of several types.

Let’s modify both addToLunchbox() and getFromLunchbox() to accept either Food or Water:

const addToLunchbox = (item: Food | Water): void => {
	lunchbox.push(item);
}

const getFromLunchbox(): Food | Water {
	return lunchbox.pop();
}

addToLunchbox(new ChocolateBar());
addToLunchbox(new Water());
addToLunchbox(new Brick()); // Error!

With union types you can specify two or more types that can be accepted as arguments to, or returned from, a method. Note that it is still the responsibility of the caller to interpret the returned value correctly.

Errors

You may be considering including error types when specifying return types e.g.

class EmptyLunchboxError extends Error {};

const getFromLunchbox = (): Food | Water | EmptyLunchboxError => { ... }

You should avoid doing this, particularly when throwing an error - throwing represents an exceptional state in your program execution outside of normal program flow.

© 2020 Derek Lawless. Built with Gatsby