β ESSAY
When working with union types, there are times when you want to ensure that all cases are handled without exception. Particularly in switch statements, if you forget to write code to handle a newly added case, unexpected behavior can occur at runtime.
By leveraging TypeScript's never type, you can catch these mistakes at compile time. This article explores what the exhaustive check pattern is and how to use it.
Let's say you're creating a function to handle payment methods.
type PaymentMethod = 'card' | 'bank'
function processPayment(method: PaymentMethod) {
switch (method) {
case 'card':
console.log('μΉ΄λ κ²°μ μ²λ¦¬')
break
case 'bank':
console.log('κ³μ’μ΄μ²΄ μ²λ¦¬')
break
}
}
So far, there's no problem. But what if you need to add cryptocurrency payment later?
type PaymentMethod = 'card' | 'bank' | 'crypto'
You added crypto to the type, but didn't modify the processPayment function. TypeScript won't throw any errors because a switch statement without a default case is still syntactically valid.
processPayment('crypto') // Nothing gets printed
At runtime, when attempting to pay with crypto, the switch statement won't match any cases and will simply pass through. Such bugs are easy to miss in testing and can cause major issues if discovered in production.
To understand exhaustive checking, you first need to understand the never type.
never in TypeScript represents a type that can never occur. It's like the empty set (β
) in mathematics. No value can be assigned to the never type.
let value: never
value = 1 // β Error: Type 'number' is not assignable to type 'never'
value = 'hello' // β Error: Type 'string' is not assignable to type 'never'
value = null // β Error: Type 'null' is not assignable to type 'never'
never typically appears in situations like these:
// Functions that never return
function throwError(message: string): never {
throw new Error(message)
}
// Infinite loops
function infiniteLoop(): never {
while (true) {}
}
One of TypeScript's powerful features is Control Flow Analysis. It follows code branches to narrow down variable types.
type PaymentMethod = 'card' | 'bank' | 'crypto'
function process(method: PaymentMethod) {
if (method === 'card') {
// Here, method's type is 'card'
} else if (method === 'bank') {
// Here, method's type is 'bank'
} else {
// Here, method's type is 'crypto'
}
}
When all cases are handled, after the final else block, there are no possible types left for method. In other words, it becomes never.
function process(method: PaymentMethod) {
if (method === 'card') {
return
} else if (method === 'bank') {
return
} else if (method === 'crypto') {
return
}
// Here, method's type is never
// This code is unreachable since all cases have been handled
method // never
}
This raises a crucial question: How can TypeScript perform this validation at compile time?
TypeScript's type system is based on set theory. The union type 'card' | 'bank' | 'crypto' is equivalent to the set {'card', 'bank', 'crypto'} with three elements.
type PaymentMethod = 'card' | 'bank' | 'crypto'
// Represented as a set: { 'card', 'bank', 'crypto' }
When encountering conditional statements in control flow, TypeScript performs operations that remove elements from the set.
function process(method: PaymentMethod) {
// method: { 'card', 'bank', 'crypto' }
if (method === 'card') {
// method: { 'card' } (other elements removed)
return
}
// method: { 'bank', 'crypto' } ('card' removed)
if (method === 'bank') {
// method: { 'bank' }
return
}
// method: { 'crypto' } ('bank' also removed)
if (method === 'crypto') {
// method: { 'crypto' }
return
}
// method: { } (empty set = never)
}
Each branch removes the corresponding case from the set of possible types. When all cases are handled, you get an empty set, which is never.
In TypeScript, being able to assign A to B means that set A is a subset of set B.
type A = 'card'
type B = 'card' | 'bank'
let b: B = 'card' as A // β
{ 'card' } β { 'card', 'bank' }
Since never is the empty set, never is a subset of every type. Therefore, never can be assigned to anything.
declare const n: never
const a: string = n // β
Empty set is a subset of every set
const b: number = n // β
Conversely, a non-empty set cannot be a subset of the empty set. Therefore, no value can be assigned to never.
const x: never = 'card' // β { 'card' } β { }
Now let's see the complete picture. The TypeScript compiler goes through the following process:
type PaymentMethod = 'card' | 'bank' | 'crypto'
function processPayment(method: PaymentMethod) {
switch (method) {
case 'card':
// ... handle
break
case 'bank':
// ... handle
break
default:
const _check: never = method
// ^^^^^^^^^^^^^^^^^^^^^^^
// Compiler checks this assignment
}
}
Type Collection: The compiler knows that method's initial type is 'card' | 'bank' | 'crypto'.
Branch Analysis: After passing case 'card', 'card' is removed, and after case 'bank', 'bank' is removed.
Type Calculation When Reaching Default: In the default block, method's type is 'crypto' (the unhandled case).
Assignability Check: Check if 'crypto' can be assigned to never in const _check: never = method.
Error Generation: Since { 'crypto' } β { }, assignment is impossible. Compile error.
This entire process is performed using only type information without code execution. This is why compile-time validation is possible.
If it's validated at compile time, why is throw new Error(...) still necessary?
default:
const _check: never = method
throw new Error(`Unhandled: ${method}`) // Why this?
There are two reasons.
First, defensive programming. TypeScript types disappear after compilation. If an unexpected value enters at runtime (e.g., an external API returns a new payment method), the type system cannot prevent this.
// When receiving API response as any
const method = apiResponse.paymentMethod as PaymentMethod
// Could actually be 'bitcoin'!
Second, satisfying function return types. When a function must return a value, the compiler might warn that "not all paths return a value." Adding throw makes it explicit that this path never returns normally.
function getLabel(method: PaymentMethod): string {
switch (method) {
case 'card':
return 'μΉ΄λ'
case 'bank':
return 'κ³μ’μ΄μ²΄'
default:
const _: never = method
throw new Error() // Without this line, "not all paths return" warning
}
}
Now we can implement exhaustive checking by combining the never type with control flow analysis.
type PaymentMethod = 'card' | 'bank'
function processPayment(method: PaymentMethod) {
switch (method) {
case 'card':
console.log('μΉ΄λ κ²°μ μ²λ¦¬')
break
case 'bank':
console.log('κ³μ’μ΄μ²΄ μ²λ¦¬')
break
default:
const _exhaustiveCheck: never = method
throw new Error(`Unhandled payment method: ${_exhaustiveCheck}`)
}
}
The key is assigning method to a never type variable in the default case.
If all cases are handled, the default is unreachable, so method's type becomes never. Assigning never to never is valid, so no error occurs.
But what if you miss a case?
type PaymentMethod = 'card' | 'bank' | 'crypto'
function processPayment(method: PaymentMethod) {
switch (method) {
case 'card':
console.log('μΉ΄λ κ²°μ μ²λ¦¬')
break
case 'bank':
console.log('κ³μ’μ΄μ²΄ μ²λ¦¬')
break
default:
const _exhaustiveCheck: never = method
// β Error: Type 'string' is not assignable to type 'never'
// More precisely: Type '"crypto"' is not assignable to type 'never'
throw new Error(`Unhandled payment method: ${_exhaustiveCheck}`)
}
}
Since the crypto case wasn't handled, when reaching default, method's type is 'crypto'. 'crypto' cannot be assigned to never, so a compile error occurs.
This is the essence of exhaustive checking. You can catch missing cases at compile time.
Since writing this pattern every time is cumbersome, it's convenient to create a helper function.
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${value}`)
}
Usage is simple:
function processPayment(method: PaymentMethod) {
switch (method) {
case 'card':
console.log('μΉ΄λ κ²°μ μ²λ¦¬')
break
case 'bank':
console.log('κ³μ’μ΄μ²΄ μ²λ¦¬')
break
default:
assertNever(method, `μ μ μλ κ²°μ μλ¨: ${method}`)
}
}
Since assertNever's return type is never, TypeScript knows this function never returns normally. Therefore, type inference works correctly even in code after the switch statement.
This is useful when handling action types in the Redux pattern.
type Action =
| {type: 'INCREMENT'}
| {type: 'DECREMENT'}
| {type: 'RESET'; payload: number}
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
case 'RESET':
return action.payload
default:
return assertNever(action)
}
}
When adding a new action, if the reducer doesn't handle that action, a compile error occurs.
Exhaustive checking also shines when implementing state machines.
type State = 'idle' | 'loading' | 'success' | 'error'
function getStatusMessage(state: State): string {
switch (state) {
case 'idle':
return 'λκΈ° μ€'
case 'loading':
return 'λ‘λ© μ€...'
case 'success':
return 'μλ£!'
case 'error':
return 'μ€λ₯ λ°μ'
default:
return assertNever(state)
}
}
When used with discriminated unions, it becomes even more powerful.
type Shape =
| {kind: 'circle'; radius: number}
| {kind: 'rectangle'; width: number; height: number}
| {kind: 'triangle'; base: number; height: number}
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return (shape.base * shape.height) / 2
default:
return assertNever(shape)
}
}
Actually, such completeness checking for pattern matching is often provided by default in other languages.
In Rust, if a match expression doesn't handle all cases, a compile error occurs.
enum PaymentMethod {
Card,
Bank,
Crypto,
}
fn process(method: PaymentMethod) {
match method {
PaymentMethod::Card => println!("μΉ΄λ"),
PaymentMethod::Bank => println!("κ³μ’μ΄μ²΄"),
// β Compile error: Crypto not handled
}
}
Functional languages like Haskell and OCaml also perform completeness checking by default for pattern matching.
In TypeScript, since this feature isn't enforced at the language level, you need to implement it yourself using the never type pattern. While slightly cumbersome, it's a sufficiently practical solution.
The exhaustive check pattern is a powerful way to verify at compile time that all cases of a union type are handled without exception in TypeScript. The core principle is simple:
never when all cases are handled.never type.As codebases grow and union type cases increase, this pattern's value becomes even more apparent. During refactoring, the compiler tells you about missed parts, significantly reducing runtime bugs.