TypeScript's Discriminated Unions With Real Use Cases
Discriminated Unions are a powerful TypeScript feature that can lead to some very robust code that scales well on large teams. However it is a rather underrated & under utilised pattern. I'm going to give a real world example for using discriminated unions with a Redux example. You could also apply this pattern to message handlers, mappers, and more.
Reducers
In this example we will build a simple reducer for managing some settings in an application. In this case, the discriminated union helps:
- ensure our code addresses every possible action in the system
- gives us fantastic type hints inside the branches of our code
- prevents us from attempting to access the wrong payload attributes
Let's take a look at the code first, then I will show you examples of modifying this code and how it helps prevent errors.
/*
Lets imagine we're creating a settings page. Here the user can:
- toggle sound on/off
- change the appearance (theme)
- enable/disable noitifications
- Set their username
*/
type SettingsState = {
isSoundOn: boolean;
areNotificationsEnabled: boolean;
theme: 'DARK' | 'LIGHT' | 'OCEANIC';
username: string;
};
type SetSoundAction = {
type: 'SET_SOUND';
payload: {
isSoundOn: boolean;
};
};
type SetThemeAction = {
type: 'SET_THEME';
payload: {
themeName: SettingsState['theme'];
};
};
type EnableNotificationAction = {
type: 'ENABLE_NOTIFICATION';
};
type DisableNotificationAction = {
type: 'DISABLE_NOTIFICATION';
};
type SetUsernameAction = {
type: 'SET_USERNAME';
payload: {
username: string;
};
};
type SettingsAction =
| SetSoundAction
| SetThemeAction
| EnableNotificationAction
| DisableNotificationAction
| SetUsernameAction;
const initialState: SettingsState = {
isSoundOn: false,
areNotificationsEnabled: false,
theme: 'LIGHT',
username: '',
};
function settingsReducer(
state = initialState,
action: SettingsAction
): SettingsState {
switch (action.type) {
case 'SET_SOUND':
return {
...state,
isSoundOn: action.payload.isSoundOn,
};
case 'SET_THEME':
return {
...state,
theme: action.payload.themeName,
};
case 'ENABLE_NOTIFICATION':
return {
...state,
areNotificationsEnabled: true,
};
case 'DISABLE_NOTIFICATION':
return {
...state,
areNotificationsEnabled: false,
};
case 'SET_USERNAME':
return {
...state,
username: action.payload.username,
};
default:
return isNeverAction(action.type, 'settingsReducer');
}
}
function isNeverAction(action: never, reducer: string): never {
throw new Error(`${reducer} received invalid action ${action}`)
}
Let's take a look at what happens if someone adds an action and forgets to write a reducer for it.
I'll add an action like this:
// other actions above
type SetEmailPreferences = {
type: 'SET_EMAIL_PREFERENCES';
payload: {
marketing: boolean;
transactional: boolean;
};
};
type SettingsAction =
| SetSoundAction
| SetThemeAction
| EnableNotificationAction
| DisableNotificationAction
| SetUsernameAction
| SetEmailPreferences;
Now if I try to compile I get the following error
src/discriminated_unions.ts:99:28 - error TS2345: Argument of type 'SetEmailPreferences' is not assignable to parameter of type 'never'.
99 return isNeverAction(action, 'settingsReducer');
~~~~~~
And in my IDE this looks like
Awesome! Let's take a quick look at that type hinting.
TypeScript is able to tell WebStorm that the available properties for action.payload
inside this code branch can only be marketing
or transactional
because we're looking at a discriminated type. An action with the type value SET_EMAIL_PREFERENCES
can only have a payload of type { transactional: boolean, marketing: boolean }
Anatomy of a Discriminated Union
The most important aspect of a discriminated union is a property that acts as what is called the "discriminator". This is the property the compiler will use to differentiate between the different possible types. In the above example the discriminator was type
, but it could be called anything. Another common name for discriminator properties is kind
.
The discriminated union works because the discriminator itself is a narrow type, the union of possible types.
If we were to inspect the value of the discriminator in the above example, it is:
This has happened because we used a literal member to type our discriminator in each of our action types.
type SetEmailPreferences = {
type: 'SET_EMAIL_PREFERENCES'; // This is a literal member
payload: {
marketing: boolean;
transactional: boolean;
};
};
In the above example, type
is a literal member because it is a value in a type definition. This is the key to implementing discriminated unions.
What about that never?
We used the never
type to implement exhaustive checking. This means the code won't compile unless we've addressed every type with a specific case in the switch.
How does this work? Let's break down the isNeverAction
function
function isNeverAction(action: never, reducer: string): never {
throw new Error(`${reducer} received invalid action ${action}`)
}
Never is a type that won't accept any value, and we've used it for our action
parameter. Passing any type to a never will always produce a compile time error. For example
const cantSetMe: never = '';
This won't compile because ''
is a string and we're trying to assign it to a never.
Also notice that isNeverAction
also returns a never. This is the true return type for a function that can only throw. It doesn't return void
because in fact it can never return at all.
In the code, we called the isNeverAction
function inside the default case at the very end of the switch statement. However TypeScript knows that the expression in the switch can only be the values in the type SettingsAction['type']
, so if you provide a case statement for each one, and return or break in that statement, then it cannot reach the default case.
However, if we do forget a case, then we pass action.type
to the never, and TypeScript will complain.
So why do we wrap this up in a function that throws? Well, TypeScript checks only work at compile time. If this were say, a discriminated union based on types from a database or 3rd party API, then we could encounter unpredicted types at runtime. If this does happen, it means the application has corrupt data and is in an unknown state. We should throw an error and stop what we're doing. This will help us find and diagnose errors early.
What exactly is a never
?
The never
type is what we would call a bottom
type in Set theory. Huh? So a type
is really the set of all possible values. For example, the type string
is the set of every possible string ever.
So type Foo = 'foo';
is a member of the set string
. It's also a member of the set string | number
as is 0
, or any number.
It's also true that every type is a member of the set unknown
, which is every possible value. We call unknown
the top type, because it is a superset of all other types.
Well if never
is the bottom type and unknown
is the top, does that mean it is the opposite of unknown? Uh, kind of. Unknown
is the superset of any type, but never
is a subtype of every set.
Yes, that means string
contains never
, as does number
, but you cannot set a never
to any value from string
because never
is an empty set.
never
is even a subtype of sets you may make, so 'FOO' | 'BAR' | 'SAUSAGE'
includes never
, but never
of course cannot include the set.
This means that any given set, in union with never
, is equivalent to the set. never
is the identity element of the union
operation.
T | never =>T
// more concretely
'FOO' | 'BAR' | never => 'FOO' | 'BAR'
You'll recognise that a union with never
is very similar to addition with 0
, i.e.
number + 0 => number
1234 + 0 => 1234
Yes, 0
is the identity element of the addition operation.
TLDR: Check your unions against never to implement exhaustive checking.
Conclusion
Hopefully now you have a new tool up your sleeve to help you write reliable code. Yet again, TypeScript saves us precious time and effort by raising errors in our code at compile time rather than runtime. This pattern gives us a powerful way for TypeScript to yell at us when we make changes and forget something, especially in a large code base with several developers.
And a powerful use-case for the never
type! Have you seen any other great usages of discriminated unions or never? Let us know in the comments.