본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

판별 가능한 유니언: 상태를 불가능한 조합 없이 모델링해

~12 min · unions-intersections, discriminated-unions, tagged-unions, state-machines

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"모양이 다른 여러 상태를 하나의 객체에 선택 속성으로 쑤셔 넣지 마. 각 상태가 자기 필드를 갖게 해."

공통 판별 필드

판별 가능한 유니언은 모든 변형이 같은 이름의 필드를 갖고, 각 변형에서 서로 다른 리터럴 값을 사용하는 객체 유니언이야. 원, 사각형, 직사각형이 모두 kind를 가지되 값이 'circle', 'square', 'rect'로 갈리는 식이지.

shape.kind === 'circle'을 확인하면 컴파일러는 shape 전체가 원 변형이라고 알아. radius를 안전하게 읽고, 그 분기에서 side를 읽으면 오류를 보여 줘. 한 필드의 증거가 객체 전체의 모양을 좁힌다.

불가능한 상태를 만들 수 없게 해

로딩 상태를 { loading: boolean; data?: T; error?: Error } 하나로 나타내면 loading이 true인데 data와 error가 동시에 있는 조합도 타입상 가능해. 판별 유니언으로 idle, loading, success, error를 각각 나누면 각 상태에 필요한 필드만 존재하고 불가능한 조합은 만들 수 없어.

좋은 타입은 잘못된 값을 잡는 데서 끝나지 않아. 잘못된 상태를 표현할 문법 자체를 없애. 상태 기계, 이벤트, 명령, API 결과에서 이 패턴이 반복되는 이유야.

판별 필드 이름은 일관되게

kind, type, status, tag 가운데 도메인에 맞는 이름을 골라. 구체 이름보다 프로젝트 전체의 일관성이 더 중요해. 값의 타입은 넓은 string이 아니라 정확한 리터럴이어야 좁히기가 작동한다.

switch와 함께 읽기 좋아

변형이 둘이면 if도 충분하지만 수가 늘면 switch가 전체 목록을 한눈에 보여 줘. 각 case 안에서는 전용 필드가 열리고, 마지막에 assertNever를 두면 새 변형을 추가했을 때 빠진 처리 지점을 컴파일러가 찾아 준다.

판별 필드 이름은 프로젝트 안에서 통일해. React reducer에서는 type, 함수형 언어에서 옮겨 온 코드에서는 tag가 자주 보이고, 도메인 모델에서는 JavaScript의 다른 type 의미와 겹치지 않도록 kind를 고르기도 해.

‘이것 또는 저것이며 모양도 달라진다’면 판별 가능한 유니언부터 생각해. 데이터가 상태 기계를 스스로 설명하게 된다.

피파의 고백

선택 속성 여러 개로 상태를 만들면 렌더 함수마다 ‘이 조합이 진짜 가능한가?’를 다시 추론하게 돼. 판별 필드 하나를 세우면 타입이 그 질문에 답하고, 사람은 각 상태에서 해야 할 일만 읽으면 돼.

Code

도형 패턴·typescript
// 도형 — canonical 예시.
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;   // s 가 circle variant 로 narrow
    case 'square': return s.side ** 2;
    case 'rect':   return s.w * s.h;
  }
}

// 각 분기가 어느 variant 인지 정확히 앎.
// Compiler 가 추적: 'circle' 분기 안에서 s.radius 접근 가능; s.side 안 됨.
State machine 과 Redux action — 같은 패턴·typescript
// State machine.
type QueryState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function render<T>(state: QueryState<T>) {
  switch (state.status) {
    case 'idle':    return 'waiting';
    case 'loading': return 'spinner';
    case 'success': return `data: ${JSON.stringify(state.data)}`;     // state.data 만 여기서
    case 'error':   return `error: ${state.error.message}`;            // state.error 만 여기서
  }
}

// Redux-style action 타입.
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number };

function reduce(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    case 'SET':       return action.payload;     // payload 만 여기서
  }
}

External links

Exercise

HTTP 처리 결과를 { status: 'json', body: unknown } | { status: 'redirect', to: string } | { status: 'error', code: number }로 모델링해. 이어서 status를 switch로 나눠 각 결과를 처리하는 respond(res, result)를 작성하고, 분기 안에서 알맞은 필드만 좁혀지는지 확인해.
Hint
status를 나눈 각 분기에서 해당 상태의 필드만 자동 완성되어야 해. 리다이렉트 분기에서 body에 접근해 좁히기가 오류를 잡는지 확인해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.