상태를 하나 추가했는데, 핸들러를 깜빡했다. 아무도 경고하지 않았다.

상태 머신은 처음엔 깔끔하다. 값 세 개, switch 가지 세 개. 그러다 재시도 로직이 네 번째 상태를 추가한다. 부분 실패가 다섯 번째를 추가한다. reducer는 업데이트했는데, 상태 배지 컴포넌트와 analytics 매퍼, 그리고 export 포맷터를 놓친다.

모든 게 컴파일된다. 버그는 두 스프린트 뒤, 수동으로 테스트하지 않은 구석진 케이스에서 사용자가 빈 화면을 만날 때 표면 위로 떠오른다.

TypeScript가 이걸 불가능하게 만들 수 있다. linter 규칙으로도, 테스트로도 아니다. 타입 시스템 자체로.

exhaustiveness checking이 실제로 무엇을 의미하는가

exhaustiveness checking은 컴파일러가 타입의 모든 variant를 처리했다는 것을 증명하는 것이다. Rust와 OCaml은 이걸 기본으로 한다. TypeScript도 가지고 있다. 하지만 직접 요청해야 한다.

이 트릭은 두 가지를 결합한다: discriminated union, 그리고 오직 never 타입만 받아들이는 헬퍼 함수.

discriminated union으로 상태를 모델링하는 방법

discriminated union은 모든 variant가 공유된 리터럴 프로퍼티를 가지는 TypeScript 타입이다. 이 프로퍼티를 discriminant라고 부른다. 상태 머신에서는 이게 거의 항상 status, kind, 또는 type 필드이다.

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

각 variant는 그 상태와 관련된 데이터만 정확히 담는다. idle에는 선택적 data 필드가 없고, success에는 message 필드가 없다. 객체의 shape가 어떤 상태인지 알려주며, 컴파일러는 switch 낶에서 자동으로 타입을 좁힌다.

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
  }
}

case "loading" 낶에서 타입은 { status: "loading"; requestId: string }이므로, state.requestId를 사용할 수 있다. 각 variant의 status 리터럴이 다르기 때문에 컴파일러가 자동으로 타입을 좁힌다.

이건 string enum이나 boolean flag soup보다 낫다. 하지만 아직 exhaustive하지 않다. error 케이스를 지워도 TypeScript는 여전히 컴파일된다. 함수가 암묵적으로 undefined를 반환하고, 컴파일러는 조용하다.

never 트릭: 강제로 완전 처리를 만드는 방법

헬퍼 함수는 이렇다:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}

이 함수는 오직 never, 즉 빈 union, 값을 가질 수 없는 타입만 받는다. 컴파일러에게 거짓말하지 않는 이상, 실제 값으로 assertNever를 호출하는 것은 불가능하다.

이제 switch 문 맨 밑에 추가한다:

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
    default:
      return assertNever(state)
  }
}

State의 모든 가능한 variant가 default 위에서 처리되었다면, default 브랜치 안의 statenever로 좁혀진다. 그때 assertNever(state)는 컴파일된다.

새 상태를 추가하고 케이스를 잊으면, default 브랜치에 실제 타입이 들어온다. 실제 타입을 never 파라미터에 넘길 수 없다. 컴파일러가 타입 에러를 던진다.

실제로 실패하는 모습을 지켜보기

retrying 상태를 추가한다:

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number; lastError: string }

이제 handleState는 컴파일되지 않는다:

Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.

에러는 default 브랜치를 정확히 가리킨다. 런타임 크래시가 아니다. 이 특정 함수에서 retrying이 무엇을 의미하는지 결정하기 전까지는 빌드를 거부하는 컴파일 타임 에러다.

State를 switch하는 각 함수마다 별도의 에러가 발생한다. 컴파일러는 작업을 미루게 하지 않는다.

이 패턴은 모든 union type으로 확장된다

이건 상태 머신에만 국한되지 않는다. 어떤 discriminated union이든 같은 대우를 받는다.

이벤트 핸들러:

type Event =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "PAGE_VIEW"; path: string }

function handleEvent(event: Event): void {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId)
      return
    case "USER_LOGOUT":
      trackLogout()
      return
    case "PAGE_VIEW":
      trackPageView(event.path)
      return
    default:
      assertNever(event)
  }
}

Redux 스타일 reducer:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount
    case "decrement":
      return state - action.amount
    case "reset":
      return 0
    default:
      return assertNever(action)
  }
}

리터럴 discriminant를 가진 객체의 union. 이것으로 switch한다. assertNever를 default로 둔다. variant를 하나 추가하면, 모든 switch가 터진다. 처리할 때까지.

한계와 대응책

exhaustiveness checking은 마법이 아니다. 어디까지나 adopt하기 전에 날카로운 모서리들을 알아두라.

discriminated union을 사용해야 한다

이건 plain string enum이나 boolean flag에서는 작동하지 않는다. 컴파일러는 공유된 리터럴 프로퍼티가 필요하다.

// This does NOT work for exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever는 닫힌 union에서만 exhaustiveness를 증명한다. enum을 쓰면 컴파일러는 임의의 매칭 문자열도 유효하다고 가정한다. 누락된 케이스를 증명할 수 없다.

any나 type assertion을 사용하면 안 된다

as State로 캐스팅하거나 any 타입 응답에서 꺼내면, 컴파일러는 narrowing을 스킵한다. 경계에서 검증하고, 낶에서는 타입을 신뢰하라.

assertNever는 최후의 수단으로 런타임에 throw한다

이 함수는 우선 컴파일 타임 가드이고, 두 번째로 런타임 가드이다. 이론상 throw에 도달하지 않는다. 실무에서는 캐스팅이나 오래된 JSON 때문에 안전망이 된다. 조용히 undefined를 반환하는 것보다 낫다.

분기가 많아지면 시끄러워진다

열한 번째 케이스를 추가하면, 해당 타입으로 switch하는 모든 함수에서 에러가 발생한다. 그게 바로 목적이다. 하지만 union이 여섯에서 일곱 개 variant를 넘어가면, 더 작은 sub-union이나 별도의 머신으로 쪼개는 것을 고려하라.

전체를 다시 쓰지 않고 도입하는 방법

가장 버그를 많이 일으키는 상태 머신부터 시작하라.

  1. string이나 넓은 enum으로 타입된 status 필드를 찾는다. 닫힌 union으로 교체한다.
  2. 이것을 switch하는 함수에 assertNever를 추가한다.
  3. 컴파일러의 안내를 따른다.
  4. 새 reducer에는 assertNever가 필수라는 코드 리뷰 체크리스트를 추가한다.

XState를 쓰더라도, mapper와 selector를 작성할 때는 여전히 상태 값으로 switch해야 한다. 이 함수들도 exhaustiveness checking이 필요하다.

FAQ

if/else 대신 switch에서도 작동하나요?

예, 하지만 부서지기 쉽다. 마지막 elseassertNever를 호출해야 한다. 조건 순서를 바꾸거나 early return을 추가하면 실수로 체크를 빠뜨릴 수 있다. switch가 더 안전한 이유는 default 브랜치가 눈에 띄기 때문이다.

특정 함수에서 일부 상태를 신경 쓰지 않아도 되는 경우는?

명시적으로 처리하라. 실수로 assertNever로 빠지게 내버려 두지 마라.

case "retrying":
  // No-op in this context
  return ""

컴파일러는 결정을 눈에 보이게 만들도록 강제한다. 그게 핵심이다.

assertNever에서 값을 반환할 수 있나요?

never를 반환하는 함수는 실제로는 절대 반환하지 않으므로, 어떤 반환 타입에도 할당 가능하다. throw 대신 fallback을 원한다면:

function assertNever(value: never, fallback: string): string {
  console.error("Unhandled state:", value)
  return fallback
}

value: never 파라미터는 컴파일 타임 체크를 그대로 유지한다.

JavaScript에서도 작동하나요?

아니다. 이건 컴파일 타임 TypeScript 기능이다. 런타임에서 assertNever는 그냥 throw하는 함수일 뿐이다. 안전성은 타입 체커가 해당 코드에 도달할 수 있는 코드를 빌드 거부하는 데서 온다.

컴파일러를 두 번째 리뷰어로 만들기

상태를 추가할 때마다 reducer, UI, analytics 파이프라인, export 로직에 작업이 생긴다. 기본 결과는 이 중 하나를 놓치고 배포한 뒤, 나중에 알아차리는 것이다.

exhaustiveness checking은 기본값을 뒤집는다. 컴파일러는 switch 문을 절대 잊지 않는 리뷰어가 된다. 테스트를 대체하지는 않지만, 테스트가 거의 커버하지 못하는 버그 카테고리를 잡는다: 단순히 코드를 써야 한다는 사실을 몰랐던 경우.

헬퍼를 추가하라. 하나의 reducer에 적용하라. 다음에 상태 머신을 확장할 때, TypeScript가 어디에 작업이 남았는지 알려주게 하라. 브랜치를 놓치지 않을 것이다.