TypeScript Discriminated Union
Description
If your class has a literal member then you can use that property to discriminate between union members.
Examples
interface Square {
kind: "square"
size: number
}
interface Rectangle {
kind: "rectangle"
width: number
height: number
}
type Shape = Square | Rectangle
function area(s: Shape) {
if (s.kind === "square") {
// Now TypeScript *knows* that `s` must be a square ;)
// So you can use its members safely :)
return s.size * s.size
} else {
// Wasn't a square? So TypeScript will figure out that it must be a Rectangle ;)
// So you can use its members safely :)
return s.width * s.height
}
}
Exhaustive Checks
Quite commonly you want to make sure that all members of a union have some code(action) against them. You can do that by simply adding a fall through and making sure that the inferred type in that block is compatible with the never type. For example if you add the exhaustive check you get a nice error:
interface Square {
kind: "square"
size: number
}
interface Rectangle {
kind: "rectangle"
width: number
height: number
}
// Someone just added this new `Circle` Type
// We would like to let TypeScript give an error at any place that *needs* to cater for this
interface Circle {
kind: "circle"
radius: number
}
type Shape = Square | Rectangle | Circle
function area(s: Shape) {
if (s.kind === "square") {
return s.size * s.size
} else if (s.kind === "rectangle") {
return s.width * s.height
} else if (s.kind === "circle") {
return Math.PI * s.radius ** 2
} else {
// Okay once more
const _exhaustiveCheck: never = s
}
}
Switch
This can also be accomplished with a switch
statement:
function area(s: Shape) {
switch (s.kind) {
case "square":
return s.size * s.size
case "rectangle":
return s.width * s.height
case "circle":
return Math.PI * s.radius * s.radius
default:
const _exhaustiveCheck: never = s
}
}
Exhaustive checks
Another solution is to write a function that only accepts a never and then throws an error if its body every executes:
function assertNever(x: never): never {
throw new Error("Unexpected value. Should have been never.")
}
interface Square {
kind: "square"
size: number
}
interface Rectangle {
kind: "rectangle"
width: number
height: number
}
type Shape = Square | Rectangle
function area(s: Shape) {
switch (s.kind) {
case "square":
return s.size * s.size
case "rectangle":
return s.width * s.height
// If a new case is added at compile time you will get a compile error
// If a new value appears at runtime you will get a runtime error
default:
return assertNever(s)
}
}
Retrospective Versioning
And after you have a bunch of DTOs you realize that name was a poor choice. You can add versioning retrospectively by creating a new union with literal number (or string if you want) of DTO. Mark the version 0 as undefined and if you have strictNullChecks enabled it will just work out:
type DTO =
| {
version: undefined // version 0
name: string
}
| {
version: 1
firstName: string
lastName: string
}
// Even later
| {
version: 2
firstName: string
middleName: string
lastName: string
}
function printDTO(dto: DTO) {
if (dto.version == null) {
console.log(dto.name)
} else if (dto.version == 1) {
console.log(dto.firstName, dto.lastName)
} else if (dto.version == 2) {
console.log(dto.firstName, dto.middleName, dto.lastName)
} else {
const _exhaustiveCheck: never = dto
}
}