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