Discriminated Unions In Typescript
Published: Aug 25, 2022
Last updated: Aug 25, 2022
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
- Basic familiarity with TypeScript.
- Basic familiarity with
tsup
. - 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:
type
can be valid as long as any string is used.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
- Basic familiarity with TypeScript.
- Basic familiarity with
tsup
. - Source code
Photo credit: robanderson72
Discriminated Unions In Typescript
Introduction