Using discriminated union types in TypeScript
Antti Pitkänen
Posted on March 19, 2023
The compile time vs runtime type dilemma
TypeScript is a typed language that compiles to JavaScript, which itself doesn't have types beyond the primitives. So when a developer writes TypeScript, it then gets transpiled into JavaScript, which is then executed in the runtime (e.g. a browser, or Nodejs on the server side). The process of transpilation leverages the static type information to catch a whole category of potential bugs, but the resulting runtime code is still just typeless JavaScript.
This has some implications that surprise developers coming to TypeScript from runtime typed languages. For one, you cannot check the type of anything at runtime, beyond the primitives. In practice:
// primitives work...
const myNumber = 1;
const myString = 'some string';
typeof myNumber; // -> "number"
typeof myString; // -> "string"
// ...but more sophisticated types don't
type MyType = {
name: string;
func: () => number;
}
const myValue: MyType = {
name: 'some name for my type',
func: () => 123
}
typeof myValue; // -> "object", nothing more specific than that
The same code in TypeScript playground
The problem
So let's say we have two types, Cat
and Dog
, and an Animal
type that's a union of both:
// all animals in our case share some base attributes
type BaseAnimal = {
name: string;
isFluffy: boolean;
}
// cats meow
type Cat = BaseAnimal & {
meow: () => string;
}
// dogs bark
type Dog = BaseAnimal & {
bark: () => string;
}
type Animal = Cat | Dog;
const whiskers: Cat = {
name: 'Whiskers',
isFluffy: true,
meow: () => 'MEOWWW!' // Whiskers meows loudly
}
const pupper: Dog = {
name: 'Pupper',
isFluffy: false,
bark: () => 'awooo' // Pupper has a gentle awoo instead of a loud bark
}
console.log(whiskers.meow()) // -> "MEOWWW!"
console.log(pupper.bark()) // -> "awooo"
Now let's write some business logic for an Animal
to make noise, regardless of whether it's a Cat
or a Dog
:
const makeNoise = (animal: Animal): string => {
// ???
}
TypeScript cannot really help you here, as seen in the screenshot.
Because of the compile time vs runtime behaviour of types, you cannot do something like this:
const makeNoise = (animal: Animal): string => {
// Doesn't work because the type doesn't exist at runtime,
// typeof will just return 'object'
if (typeof animal === 'Dog') {
return animal.bark();
}
// ...
}
Instead you can do something like this, but TypeScript is not happy about it:
const makeNoise = (animal: Animal): string => {
if (typeof animal.bark === 'function') {
return animal.bark()
}
if (typeof animal.meow === 'function') {
return animal.meow()
}
return 'not implemented!'
}
console.log(makeNoise(whiskers)) // -> "MEOWWW!"
console.log(makeNoise(pupper)) // -> "awooo"
TypeScript's unhappiness comes from the fact that the .meow()
and .bark()
methods don't exist on all the variations of Animal
. Here's what the errors look like:
Even if you accepted this approach, what would happen if the complexity grows? Let's say we want to add a Cat.purr()
and Dog.growl()
method for the types, and add those to the makeNoise
output too. The code would get harder to work with right away, with more complex if
statements and more room for bugs to creep in.
The code so far can be played with in TypeScript playground.
The solution – discriminated unions
The solution for making the runtime "aware" of our compile time types is surprisingly simple. All we need is a tag for the types to be able to uniquely identify each variant, and discriminate based on that information. In practice this means adding a property with a literal value that can be used to identify each variant both at compile time and at runtime.
Note about the naming. The attribute can be called anything as long as the name is consistent between the variants. Here we use _t
. Other commonly used names are type
, tag
, or kind
, with or without an underscore. It could also be something more relevant to the domain that's being modelled, so in our case of animals we could use something like species
.
// cats meow
type Cat = BaseAnimal & {
_t: 'cat', // <- the discriminator for cat
meow: () => string;
}
// dogs bark
type Dog = BaseAnimal & {
_t: 'dog', // <- the discriminator for dog
bark: () => string;
}
type Animal = Cat | Dog;
Note that the value of _t
needs to be unique for TypeScript to be able to uniquely identify each variant. Now the business logic becomes easier: all we need to do is check for the value of _t
, and the TypeScript compiler knows to automatically narrow the type based on it. For example, and Animal
with _t === 'dog'
can be narrowed from Animal
to Dog
.
Note also the assertNever
helper. Here it's used to make sure that all the different variants are handled in the branches of the switch
statement.
const assertNever = (n: never): never => {
throw new Error('Should never happen')
}
const makeNoise = (animal: Animal): string => {
switch (animal._t) {
case 'cat':
return animal.meow();
case 'dog':
return animal.bark();
default:
return assertNever(animal);
}
}
The following screenshot illustrates how the compiler is able to narrow down from the generic Animal
to the specific Cat
and Dog
based on the code branch we're in.
The solution code as a whole can be found here.
Practical example – async HTTP states
While the previous example was hopefully illustrative, we rarely write code about cats and dogs. A more useful and complete example could be the familiar situation in SPA applications fetching data: rendering the different states.
Our imaginary application starts with a clean state, then performs an asynchronous HTTP API call to fetch some data, and then renders the resulting success or error based on what happened with the request. Here's a minimal example:
type Data = {
items: string[];
};
type State =
| { _t: "initial" }
| { _t: "loading" }
| { _t: "error"; err: Error }
| { _t: "success"; data: Data };
As you can see, we can enumerate the different known states using discriminated unions. This way, when we write the rendering logic, we don't need to do any "if data then do something with data" type of checking. Instead we can just check for the value of _t
, and the compiler knows to narrow it down based on that.
Note again that _t
could be called anything else too, as long as the name is consistent between the variants. In this case another example of a sensible discriminator name could be status
.
The example is in react, but could be done in any other rendering pattern as well.
const renderer = (state: State) => {
switch (state._t) {
case "initial":
return <p>Click button to start</p>;
case "loading":
return <p>Loading...</p>;
case "error":
return (
<div>
<h3>Oops, error happened!</h3>
<p>{state.err.message}</p>
</div>
);
case "success":
return (
<div>
<h3>Here's your data:</h3>
<ol>
{state.data.items.map((i) => (
<li key={i}>{i}</li>
))}
</ol>
</div>
);
}
}
Here's the full working example in CodeSandbox: https://codesandbox.io/s/typescript-discriminate-unions-in-async-state-rendering-l2jbdl?file=/src/App.tsx
Other use cases
I have found discriminated unions especially helpful in situations where the result of an operation needs to be categorized into a success of a failure on a high level (hint: the Either monad is a super useful pattern), and the different variants of successes and/or failures categorized even deeper. So think something like:
type Failure = LogicFailure | DatabaseFailure;
type Success = SuccessWithData | SuccessWithoutData;
type Result = Failure | Success;
You can see how the discriminated union pattern helps us compose the larger data types out of smaller enumerated pieces, and build the logic around what happens when each small piece is handled. This is useful for representing expected different outcomes of functions instead of throwing errors, and this is discussed more in a separate post of mine.
This is also very useful for the purpose of building observability into the different variants, for example categorizing what kinds of errors are encountered. More on that in a separate post later.
Posted on March 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.