# Discriminated Unions In Typescript

> Peel back some of the magic behind discriminated union in TypeScript and how they impact your workflow.

- Canonical: https://dennisokeeffe.com/blog/2022-08-25-discriminated-unions-in-typescript
- Published: 2022-08-25
- Updated: 2022-08-25
- Tags: beginner, typescript

This post will talk through [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions), and how we can use them in TypeScript alongside narrowing to make our code more robust.

Source code can be found [here](https://github.com/okeeffed/ts-discriminated-unions)

## Prerequisites

1. Basic familiarity with [TypeScript](https://nextjs.org/docs/api-reference/create-next-app).
2. Basic familiarity with [`tsup`](https://tsup.egoist.dev/).
3. TypeScript set up on your local IDE is ideal.

## Getting started

We will create the project directory `ts-discriminated-unions` and set up some basics:

```s
$ mkdir ts-discriminated-unions
$ cd ts-discriminated-unions
$ yarn init -y
$ yarn add -D typescript tsup @tsconfig/recommended
$ mkdir src
$ touch src/index.ts tsconfig.json
```

In `tsconfig.json`, add the following:

```json
{
  "extends": "@tsconfig/recommended/tsconfig.json"
}
```

Update `package.json` to look like this:

```json
{
  "name": "ts-discriminated-unions",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Dennis O'Keeffe",
  "license": "MIT",
  "devDependencies": {
    "@tsconfig/recommended": "^1.0.1",
    "tsup": "^6.2.2",
    "typescript": "^4.7.4"
  },
  "scripts": {
    "start": "tsup src/index.ts --dts --watch --format esm,cjs",
    "build": "tsup src/index.ts --dts --format esm,cjs --minify"
  }
}
```

Update `src/index.ts` to actively have a typing issue to check things are ready:

```ts
// Contrived type issue
const hello: string = 2;
```

`tsup` won't type-check by default, but it will if we pass the `--dts` flag in.

Run `yarn dev` and you will see the following error:

```s
src/index.ts(1,7): error TS2322: Type 'number' is not assignable to type 'string'.
```

Adjust the TypeScript file to correct the issue, and the `watch` mode will succeed type check and build to `dist`.

At this point, we are ready to go.

## A simple example of using discriminated unions

Let's start with the following:

```ts
type Action = {
  type: string;
  payload: string;
};

function take(action: Action) {
  console.log(action.type);
  console.log(action.payload);
}
```

In the above, our function `take` will take an `action` of type `Action`. The type `Action` currently has two fields, `type` and `payload`.

As it currently stands, we have two limitations:

1. `type` can be valid as long as any string is used.
2. `payload` is limited in what that can be.

Let's update our code to make sense of these limitations. Say we want an action `ADD_ONE` that will return the value passed + 1.

Update `index.ts` to the following, you can start to see where this breaks down:

```ts
type Action = {
  type: string;
  payload: string;
};

function take(action: Action) {
  console.log(action.type);
  console.log(action.payload);

  switch (action.type) {
    case "ADD":
      const res = 1 + action.payload;
      console.log(res);
      return res;
    default:
      throw new Error("whoops");
  }
}

take({ type: "ADD", payload: "1" });
take({ type: "ADDs", payload: "2" });
```

Our TypeScript is valid, so `tsup` built our code out just fine.

The issue (as you can see) is that adding 1 to a string is valid, and setting any `type` that is a string is also valid.

If we run the built code with `yarn start` in another terminal window, we get the following:

```s
$ node dist/index.js
ADDs
1
11
ADD
1
/Users/dennisokeeffe/code/projects/ts-discriminated-unions/dist/index.js:13
      throw new Error("whoops");
      ^

Error: whoops
    at take (/Users/dennisokeeffe/code/projects/ts-discriminated-unions/dist/index.js:13:13)
    at Object.<anonymous> (/Users/dennisokeeffe/code/projects/ts-discriminated-unions/dist/index.js:17:1)
```

Uh-oh. Our code is definitely not returning what we might expect, and we managed to still write code that will fall into our `default` state.

So what can we do to fix this? We can use the power of discriminated unions to make our code more robust.

## Amending our code to use discriminated unions

Update the code in `index.ts` to the following:

```ts
type AddOneAction = {
  type: "ADD_ONE";
  payload: number;
};

type AddTwoAction = {
  type: "ADD_TWO";
  payload: number;
};

type Action = AddOneAction | AddTwoAction;

function take(action: Action) {
  console.log(action.type);
  console.log(action.payload);

  switch (action.type) {
    case "ADDs":
      const res = 1 + action.payload;
      console.log(res);
      return res;
  }
}

take({ type: "ADDs", payload: "1" });
take({ type: "ADD", payload: "1" });
```

As soon as we update those types, we will get some type errors straight away:

```s
src/index.ts(18,10): error TS2678: Type '"ADDs"' is not comparable to type '"ADD_ONE" | "ADD_TWO"'.
src/index.ts(19,30): error TS2339: Property 'payload' does not exist on type 'never'.
src/index.ts(25,8): error TS2322: Type '"ADDs"' is not assignable to type '"ADD_ONE" | "ADD_TWO"'.
src/index.ts(25,22): error TS2322: Type 'string' is not assignable to type 'number'.
src/index.ts(26,8): error TS2322: Type '"ADD"' is not assignable to type '"ADD_ONE" | "ADD_TWO"'.
src/index.ts(26,21): error TS2322: Type 'string' is not assignable to type 'number'.
```

Perfect! This time we know that we to update our action types. Let's update our code again to rectify these first issues:

```ts
type AddOneAction = {
  type: "ADD_ONE";
  payload: number;
};

type AddTwoAction = {
  type: "ADD_TWO";
  payload: number;
};

type Action = AddOneAction | AddTwoAction;

function take(action: Action) {
  console.log("Running action", action.type);
  switch (action.type) {
    case "ADD_ONE":
      const addOneRes = 1 + action.payload;
      console.log(addOneRes);
      return addOneRes;
    case "ADD_TWO":
      const addTwoRes = 2 + action.payload;
      console.log(addTwoRes);
      return addTwoRes;
  }
}

take({ type: "ADD_ONE", payload: 1 });
take({ type: "ADD_TWO", payload: 2 });
```

Our code now compiles and we can run `yarn start` again in the other terminal to see what happens:

```ts
$ yarn start
yarn run v1.22.19
$ node dist/index.js
Running action ADD_ONE
2
Running action ADD_TWO
4
```

Awesome! What happens if we want to have some different payloads types? Let's have a look.

## Different payload types with discriminated unions

Let's introduce a third action `RETURN_USER_NAME` that will return the name of a user given a payload of type `User` (contrived, I know).

Update `index.ts` to the following:

```ts
type AddOneAction = {
  type: "ADD_ONE";
  payload: number;
};

type AddTwoAction = {
  type: "ADD_TWO";
  payload: number;
};

type User = {
  name: string;
  age: number;
};

type ReturnUserNameAction = {
  type: "RETURN_USER_NAME";
  payload: User;
};

type Action = AddOneAction | AddTwoAction | ReturnUserNameAction;

function take(action: Action) {
  console.log("Log user name", action.payload.name);

  switch (action.type) {
    case "ADD_ONE":
      const addOneRes = 1 + action.payload;
      console.log(addOneRes);
      return addOneRes;
    case "ADD_TWO":
      const addTwoRes = 2 + action.payload;
      console.log(addTwoRes);
      return addTwoRes;
    case "RETURN_USER_NAME":
      const name = action.payload.name;
      console.log(name);
      return name;
  }
}

take({ type: "ADD_ONE", payload: 1 });
take({ type: "ADD_TWO", payload: 2 });
take({
  type: "RETURN_USER_NAME",
  payload: {
    name: "Dennis",
    age: 30,
  },
});
```

You'll notice something fascinating in the code. It fails to pass type checking at the top of `take` where we attempt to log the name of a user, **but not within our `case "RETURN_USER_NAME"` block**.

Why is that? TypeScript is smart enough to know when we are in a branch where a value is certain to exist! This is where the real power of discriminated unions comes in. If we remove the top `console.log` statement, our code will compile fine.

Running `yarn start` will yield the following:

```s
$ yarn start
yarn run v1.22.19
$ node dist/index.js
2
4
Dennis
```

## Bonus: Handling exhausting switches

We have one last problem left to solve.

Say we adjust our code to this:

```ts
type AddOneAction = {
  type: "ADD_ONE";
  payload: number;
};

type AddTwoAction = {
  type: "ADD_TWO";
  payload: number;
};

type User = {
  name: string;
  age: number;
};

type ReturnUserNameAction = {
  type: "RETURN_USER_NAME";
  payload: User;
};

type Action = AddOneAction | AddTwoAction | ReturnUserNameAction;

function take(action: Action) {
  switch (action.type) {
    case "ADD_ONE":
      const addOneRes = 1 + action.payload;
      console.log(addOneRes);
      return addOneRes;
    case "ADD_TWO":
      const addTwoRes = 2 + action.payload;
      console.log(addTwoRes);
      return addTwoRes;
  }
}

take({ type: "ADD_ONE", payload: 1 });
take({ type: "ADD_TWO", payload: 2 });
take({
  type: "RETURN_USER_NAME",
  payload: {
    name: "Dennis",
    age: 30,
  },
});
```

If we remove one of our `switch` cases, our code will succeed in compiling. In most cases, we can set up a `default` statement, but alternatively we can use `ESLint` to help us with our exhaustive switches.

To set up, run the following in the terminal:

```s
$ yarn add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ touch .eslintrc
```

And add the following to `.eslintrc`:

```json
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "rules": {
    "@typescript-eslint/switch-exhaustiveness-check": "warn"
  }
}
```

By default, we need to include the `switch-exhaustiveness-check` rule to make sure we include all possible branches.

We can also update our `lint` script within `package.json`:

```json
{
  "name": "ts-discriminated-unions",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Dennis O'Keeffe",
  "license": "MIT",
  "devDependencies": {
    "@tsconfig/recommended": "^1.0.1",
    "@typescript-eslint/eslint-plugin": "^5.35.1",
    "@typescript-eslint/parser": "^5.35.1",
    "eslint": "^8.22.0",
    "tsup": "^6.2.2",
    "typescript": "^4.7.4"
  },
  "scripts": {
    "start": "node dist/index.js",
    "dev": "tsup src/index.ts --dts --watch --format esm,cjs",
    "build": "tsup src/index.ts --dts --format esm,cjs --minify",
    "prebuild": "yarn lint",
    "lint": "eslint  . --ext .ts"
  }
}
```

Now if we run `yarn lint`, we will get the following:

```s
$ yarn lint
yarn run v1.22.19
$ eslint  . --ext .ts

/Users/dennisokeeffe/code/projects/ts-discriminated-unions/src/index.ts
  24:11  warning  Switch is not exhaustive. Cases not matched: "RETURN_USER_NAME"  @typescript-eslint/switch-exhaustiveness-check
  26:7   error    Unexpected lexical declaration in case block                     no-case-declarations
  30:7   error    Unexpected lexical declaration in case block                     no-case-declarations

✖ 3 problems (2 errors, 1 warning)

error Command failed with exit code 1.
```

The first error itself tells us that our switch is no longer exhaustive, and it even tells us the missing cases! Perfect. We also need to fix some lexical declarations, which we can do by cleaning up our code to no longer cause an logging side-effects.

Update `src/index.ts` to look like this:

```ts
type AddOneAction = {
  type: "ADD_ONE";
  payload: number;
};

type AddTwoAction = {
  type: "ADD_TWO";
  payload: number;
};

type User = {
  name: string;
  age: number;
};

type ReturnUserNameAction = {
  type: "RETURN_USER_NAME";
  payload: User;
};

type Action = AddOneAction | AddTwoAction | ReturnUserNameAction;

function take(action: Action) {
  switch (action.type) {
    case "ADD_ONE":
      return 1 + action.payload;
    case "ADD_TWO":
      return 2 + action.payload;
    case "RETURN_USER_NAME":
      return action.payload.name;
  }
}

take({ type: "ADD_ONE", payload: 1 });
take({ type: "ADD_TWO", payload: 2 });
take({
  type: "RETURN_USER_NAME",
  payload: {
    name: "Dennis",
    age: 30,
  },
});
```

Now if we run `yarn build` to finish it off, you'll notice that we pass both type checking and ESLint checks.

## A final case on discriminated unions

Let's change the code entirely. Update `index.ts` to the following:

```ts
type BaseUser = {
  name: string;
  age: number;
};

type SuperUser = {
  superHelperFn: () => void;
};

type StandardUser = {
  standardHelperFn: () => void;
};

type User = BaseUser & (SuperUser | StandardUser);

function getUser(user: User) {
  if (user.superHelperFn) {
    user.superHelperFn();
  } else if (user.standardHelperFn) {
    user.standardHelperFn();
  }
}

getUser({
  name: "John",
  age: 30,
  superHelperFn: () => {
    console.log("Super user");
  },
});
getUser({
  name: "John",
  age: 30,
  standardHelperFn: () => {
    console.log("Standard user");
  },
});
```

In this case, we now have a type `User` that could be a `SuperUser` or a `StandardUser`.

As the code currently stands, we will get type errors:

```s
src/index.ts(17,12): error TS2339: Property 'superHelperFn' does not exist on type 'User'.
  Property 'superHelperFn' does not exist on type 'BaseUser & StandardUser'.
src/index.ts(18,10): error TS2339: Property 'superHelperFn' does not exist on type 'User'.
  Property 'superHelperFn' does not exist on type 'BaseUser & StandardUser'.
src/index.ts(19,19): error TS2339: Property 'standardHelperFn' does not exist on type 'User'.
  Property 'standardHelperFn' does not exist on type 'BaseUser & SuperUser'.
src/index.ts(20,10): error TS2339: Property 'standardHelperFn' does not exist on type 'User'.
  Property 'standardHelperFn' does not exist on type 'BaseUser & SuperUser'.
```

In our case, we do not want `standardHelperFn` on `SuperUser` and we do not want `superHelperFn` on `StandardUser`, but we will currently get type errors.

We can fix this by specifically defining the functions that **we do not want** with the type `undefined`.

```ts
type BaseUser = {
  name: string;
  age: number;
};

type SuperUser = {
  superHelperFn: () => void;
  standardHelperFn?: undefined;
};

type StandardUser = {
  standardHelperFn: () => void;
  superHelperFn?: undefined;
};

type User = BaseUser & (SuperUser | StandardUser);

function getUser(user: User) {
  if (user.superHelperFn) {
    user.superHelperFn();
  } else if (user.standardHelperFn) {
    user.standardHelperFn();
  }
}

getUser({
  name: "John",
  age: 30,
  superHelperFn: () => {
    console.log("Super user");
  },
});
getUser({
  name: "John",
  age: 30,
  standardHelperFn: () => {
    console.log("Standard user");
  },
});
```

Now our code compiles, and by running `yarn start` we get the following output:

```s
$ yarn start
yarn run v1.22.19
$ node dist/index.js
Super user
Standard user
```

Perfect!

> Note: I would opt to discriminate between the unions where possible with an extra property to individual union types (like `type: 'SUPER_USER' | 'STANDARD_USER'`) to help TypeScript decipher what is feasible in what branch, but this is still an option.

## Summary

Today's post demonstrated what discriminated unions are and how we can incorporate them into our workflow.

They are very common in typed state-reducer patterns, and are a great way to avoid the pitfalls of type-checking and type-safety.

## Resources and further reading

- Basic familiarity with [TypeScript](https://nextjs.org/docs/api-reference/create-next-app).
- Basic familiarity with [`tsup`](https://tsup.egoist.dev/).
- [Source code](https://github.com/okeeffed/ts-discriminated-unions)

_Photo credit: [`robanderson72`](https://unsplash.com/@robanderson72)_
