TypeScript's Discriminated Unions With Real Use Cases

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

Type error from never inside WebStorm

Awesome! Let's take a quick look at that type hinting.

WebStorm autocomplete only has marketing or transactional

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:

Internals of SettingsAction['type']

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.