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.
Top comments (0)