🎉 I'm releasing 12 products in 12 months! If you love product, checkout my new blog workingoutloud.dev

Back to home

Discriminated Unions In Typescript

This post will talk through discriminated unions, and how we can use them in TypeScript alongside narrowing to make our code more robust.

Source code can be found here

Prerequisites

  1. Basic familiarity with TypeScript.
  2. Basic familiarity with tsup.
  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:

$ 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:

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

Update package.json to look like this:

{ "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:

// 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:

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:

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:

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:

$ 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:

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:

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:

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:

$ 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:

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:

$ 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:

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:

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

And add the following to .eslintrc:

{ "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:

{ "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:

$ 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:

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:

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:

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.

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:

$ 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

Photo credit: robanderson72

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Discriminated Unions In Typescript

Introduction

Share this post