Skip to content
yceffort
PostsSeriesTagsAbout🧪 Research
KO

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min read
--year
ENtranslated

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ ESSAY
avatar
yceffort
2026-01-17 · 8 min read
typescript
8min read
2026year
ENtranslated

How to Check if All Cases Are Handled in TypeScript Switch Statements

Table of Contents

  • Introduction
  • The Problem
  • What is the never Type?
  • Control Flow Analysis
  • The Principle of Compile-time Validation
    • 1. Union Types Are Sets
    • 2. Type Narrowing Is Set Operations
    • 3. Assignability Checking Is Subset Checking
    • 4. The Compiler's Type Checking Process
    • 5. Why Is Runtime throw Still Needed?
  • The Exhaustive Check Pattern
  • Creating a Helper Function
  • Real-world Examples
    • Redux Reducers
    • State Machines
    • Discriminated Unions
  • What About Other Languages?
  • Conclusion
  • References

Introduction

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.

The Problem

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.

What is the never Type?

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) {}
}

Control Flow Analysis

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
}

The Principle of Compile-time Validation

This raises a crucial question: How can TypeScript perform this validation at compile time?

1. Union Types Are Sets

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' }

2. Type Narrowing Is Set Operations

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.

3. Assignability Checking Is Subset Checking

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' } ⊄ { }

4. The Compiler's Type Checking Process

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
  }
}
  1. Type Collection: The compiler knows that method's initial type is 'card' | 'bank' | 'crypto'.

  2. Branch Analysis: After passing case 'card', 'card' is removed, and after case 'bank', 'bank' is removed.

  3. Type Calculation When Reaching Default: In the default block, method's type is 'crypto' (the unhandled case).

  4. Assignability Check: Check if 'crypto' can be assigned to never in const _check: never = method.

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

5. Why Is Runtime throw Still Needed?

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
  }
}

The Exhaustive Check Pattern

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.

Creating a Helper Function

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.

Real-world Examples

Redux Reducers

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.

State Machines

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)
  }
}

Discriminated Unions

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)
  }
}

What About Other Languages?

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.

Conclusion

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:

  1. TypeScript's control flow analysis narrows the type to never when all cases are handled.
  2. No value can be assigned to the never type.
  3. Therefore, if there are unhandled cases, a compile error occurs.

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.

References

  • TypeScript Handbook: Narrowing
  • https://github.com/yceffort/blog/issues/773

Related posts

  • #typescript#backend

    Deep Dive into Effect Systems: From Monads to Algebraic Effects, and Effect-TS's Choices

    I dug deep into what Effect-TS is all about and why everyone seems so excited about it.

    2026-02-20·24 min read
  • #react#typescript

    From useEvent to useEffectEvent: React's Event Handler Stabilization Journey

    An RFC from 3 years ago finally sees the light of day

    2025-12-15·14 min read

Enjoyed this post? Subscribe via RSS to get new posts.

Subscribe via RSS →

yceffort — frontend engineer. For talks, consulting, or writing inquiries, reach out here.

← Back to the blog한국어로 읽기 →