DEV Community

derek lawless
derek lawless

Posted on • Edited on • Originally published at dereklawless.ie

Union Types in TypeScript

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
}
~~~{% endraw %}

In the example above, {% raw %}`addToLunchbox()`{% endraw %} accepts an {% raw %}`item: any`{% endraw %}, allowing a caller greater flexibility when adding items to the lunchbox:{% raw %}

~~~typescript
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."
~~~{% endraw %}

However, the loose typing also allows for unintended use:{% raw %}

~~~typescript
class Brick {}

addToLunchbox(new Brick());
getFromLunchbox().eat(); // Error!
~~~{% endraw %}

You can of course tighten up contracts by introducing a base class, say, {% raw %}`Food`{% endraw %}:{% raw %}

~~~typescript
abstract class Food {
    abstract eat(): void
}

class Apple extends Food { ...}

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

As long as {% raw %}`Brick`{% endraw %} doesn't extend {% raw %}`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:

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

While it's obviously incorrect for {% raw %}`Water`{% endraw %} to extend {% raw %}`Food`{% endraw %}, 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 {% raw %}`Food`{% endraw %} with a more generic base class providing both {% raw %}`eat()`{% endraw %} and {% raw %}`drink()`{% endraw %} 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`:

~~~typescript
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.

~~~typescript
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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)