discriminated union, 무엇이고 언제 쓰면 좋을까

discriminated union, 무엇이고 언제 쓰면 좋을까

June 16, 2026

폼을 만들다 보면 입력 종류가 자꾸 늘어납니다. 텍스트, 셀렉트, 체크박스… 종류마다 필요한 데이터가 다른데, 이걸 한 객체 타입으로 뭉뚱그리면 어떤 필드가 있는지 없는지 알 수 없는 헐거운 타입이 되어 버립니다.

discriminated union(판별 유니온)은 이 “종류마다 다른 모양”을 타입으로 정확히 표현하는 방법입니다. 종류를 가르는 공통 필드 하나를 기준으로, 각 종류가 어떤 데이터를 들고 다니는지까지 타입에 새겨 둡니다.

이 글에서는 폼 필드 하나로, discriminated union이 무엇이고 어떤 상황에서 유용한지 살펴봅니다.

종류마다 따라오는 데이터가 다를수록, discriminated union이 빛납니다.

discriminated union이란

discriminated union은 공통된 리터럴 필드를 기준으로 여러 타입을 묶은 유니온입니다. 그 공통 필드를 판별자(discriminant)라고 부릅니다.

폼 필드로 가 보죠. 같은 “필드”라도 종류마다 들고 다니는 데이터가 다릅니다.

종류(type)공통 필드고유 필드
'text'nameplaceholder
'select'nameoptions
'checkbox'namedefaultChecked

이걸 그대로 타입으로 옮기면 이렇게 됩니다.

type Field =
  | { type: 'text'; name: string; placeholder: string }
  | { type: 'select'; name: string; options: string[] }
  | { type: 'checkbox'; name: string; defaultChecked: boolean };

하나의 Fieldtype 값에 따라 갈라지고, 갈래마다 필요한 데이터가 달라집니다.

flowchart TD
    F["Field 하나"]
    F -->|"type: 'text'"| T["placeholder 필요"]
    F -->|"type: 'select'"| S["options 필요"]
    F -->|"type: 'checkbox'"| C["defaultChecked 필요"]

여기서 모든 종류가 똑같이 들고 있는 type이 판별자입니다.

판별자로 타입이 좁혀집니다

판별자가 있으면 switch (field.type)if로 분기할 때, TypeScript가 각 블록 안에서 타입을 자동으로 좁혀 줍니다.

function renderField(field: Field) {
  switch (field.type) {
    case 'text':
      return <input placeholder={field.placeholder} />; // placeholder 접근 가능
    case 'select':
      return <Select options={field.options} />; // options 접근 가능
    case 'checkbox':
      return <Checkbox defaultChecked={field.defaultChecked} />;
  }
}

text 블록 안에서는 placeholder에, select 블록 안에서는 options에 안전하게 접근합니다. 반대로 text 블록에서 options를 꺼내려 하면 타입 에러가 나요. 그 종류엔 없는 필드니까요.

이게 첫 번째 유용함입니다. 종류와 데이터가 어긋난 불가능한 조합을 타입 단계에서 막아 줍니다.

판별자로 타입이 좁혀지는 원리(Control Flow Analysis, Type Guard)가 생소하다면 if문 하나로 TypeScript가 똑똑해지는 이유를 먼저 읽으면 이 글이 더 편합니다.

빠뜨린 종류를 컴파일러가 잡아줍니다

discriminated union이 특히 빛나는 건 종류가 늘어날 때입니다. 폼에 숫자 입력을 하나 더한다고 해 보죠.

type Field =
  | { type: 'text'; name: string; placeholder: string }
  | { type: 'select'; name: string; options: string[] }
  | { type: 'checkbox'; name: string; defaultChecked: boolean }
  | { type: 'number'; name: string; min: number; max: number }; // 새로 추가

앞의 renderFieldnumber를 다루지 않습니다. 그런데 TypeScript는 아무 말도 안 해요. number 필드가 들어오면 switch를 그냥 빠져나가 undefined를 반환할 뿐입니다. 종류가 셋일 때는 눈으로 챙기지만, 늘어나고 분기가 여러 파일에 흩어지면 빠뜨린 곳을 손으로 찾기 어렵습니다.

이걸 컴파일 타임에 막는 게 완전성 검사(exhaustiveness check)이고, 도구는 never 타입입니다.

🤔 완전성 검사란?
경우의 수가 정해진 타입에서 모든 경우를 빠짐없이 처리했는지 컴파일러가 검사하게 하는 기법입니다. TypeScript에는 전용 문법이 없어서 never의 성질을 빌려 흉내 냅니다.

never는 어떻게 이걸 잡아낼까요

never는 “절대 존재할 수 없는 값”의 타입입니다. 여기서 쓸 성질은 하나예요. 어떤 타입도 never에는 할당할 수 없다.

원리는 이렇습니다. case를 하나 처리할 때마다 그 종류가 후보에서 빠지고, 모든 종류를 처리하면 default에 도달한 field는 후보가 텅 빈 never가 됩니다. 이 값을 never만 받는 함수에 넘기면, 빠뜨린 종류가 남아 있을 때만 타입이 어긋나 컴파일 에러가 납니다.

flowchart LR
    A["text | select | checkbox"]
    A -->|"case 'text' 처리"| B["select | checkbox"]
    B -->|"case 'select' 처리"| C["checkbox"]
    C -->|"case 'checkbox' 처리"| D["never (후보 없음)"]
    D -->|"assertNever(field)"| E["통과 ✅"]
    C -.->|"checkbox 빠뜨리면"| F["checkbox 남음"]
    F -.->|"assertNever(field)"| G["컴파일 에러 ❌"]
function assertNever(value: never): never {
  throw new Error(`처리하지 않은 케이스: ${JSON.stringify(value)}`);
}

function renderField(field: Field) {
  switch (field.type) {
    case 'text':
      return <input placeholder={field.placeholder} />;
    case 'select':
      return <Select options={field.options} />;
    case 'checkbox':
      return <Checkbox defaultChecked={field.defaultChecked} />;
    default:
      return assertNever(field); 
    // ❌ Argument of type '{ type: "number"; ... }'
    //    is not assignable to parameter of type 'never'.
  }
}

🤔 assertNever란?
never만 받도록 만든 함수입니다. 모든 case를 처리했다면 default의 값은 never로 좁혀져 인자로 잘 들어가지만, 빠뜨린 case가 있으면 그 종류의 타입이 남아서 never에 할당되지 못하고 컴파일 에러가 납니다. 런타임에서는 throw해서 예상 못 한 값도 잡아 줍니다.

number를 처리하지 않았으니, default에 도달하는 fieldnever가 아니라 { type: 'number'; ... }입니다. 이 값은 never 파라미터에 들어갈 수 없으니 빌드가 막혀요.

이 검사가 빛나는 또 다른 자리는 useReducer입니다. 액션도 type으로 갈리는 discriminated union이라, 새 액션을 추가하고 case를 빠뜨리면 assertNever가 같은 방식으로 잡아 줍니다. 처리를 빠뜨려도 늦어도 빌드 단계에서 걸리니, 종류가 계속 느는 코드일수록 이득이 큽니다.

언제 쓰면 좋고, 언제는 아닐까

판단 기준은 하나입니다. 종류마다 같이 따라오는 필드가 정말 다른가.

상황권장
종류마다 들고 다니는 데이터가 다름discriminated union
상태는 갈리지만 따라오는 필드는 같음boolean / optional

API 상태(로딩 / 성공 / 에러), 폼 필드, reducer 액션처럼 “종류에 따라 들고 다니는 것이 달라지는” 구조가 전자의 대표입니다. 반대로 베스트 프랙티스라고 무조건 끼워 넣으면 코드만 장황해지는 자리도 분명히 있습니다.

단순한 토글이라면 boolean이 더 명확합니다

상태가 둘뿐이고 필드가 거의 안 갈라지는 경우입니다. 예를 들어 필드의 “활성 / 비활성”을 굳이 유니온으로 만들면 이렇게 됩니다.

// 과한 설계
type FieldState = { state: 'enabled' } | { state: 'disabled' };

이건 그냥 { disabled: boolean } 한 줄이 더 읽기 쉽습니다. 갈라지는 필드가 없으니 판별자만 늘어난 셈이거든요.

판별자가 optional이거나 상속이면 좁히기가 깨집니다

discriminated union이 동작하려면 판별자가 모든 멤버에 존재하는 리터럴 필드여야 합니다. 이 조건이 깨지면 TypeScript가 좁히기를 포기해요. 대표적으로 두 가지입니다.

깨지는 경우이유
판별자가 optionalundefined가 섞여 깔끔한 리터럴이 아니게 됨
상속/교차로 끼어듦판별자와 멤버 필드가 한 리터럴에 안 모임

하나는 판별자를 optional로 둔 경우입니다.

type Field =
  | { type?: 'text'; placeholder: string }
  | { type?: 'select'; options: string[] };

function renderField(field: Field) {
  if (field.type === 'text') {
    return <input placeholder={field.placeholder} />; // ❌ narrowing 안 됨
  }
}

typeundefined 가능성이 섞이면서 판별자가 깔끔한 리터럴이 아니게 됐고, 좁히기가 동작하지 않습니다.

다른 하나는 판별자가 직접 선언되지 않고 상속이나 교차 타입으로 끼어든 경우입니다.

interface BaseField {
  type: 'text' | 'select';
}
interface SelectField extends BaseField {
  options: string[];
}

type Field = BaseField | SelectField;

function renderField(field: Field) {
  if (field.type === 'select') {
    return <Select options={field.options} />;
    // ❌ Property 'options' does not exist on type 'Field'.
  }
}

판별자와 멤버 필드를 같은 객체 리터럴 안에 직접 적어야 TypeScript가 하나의 판별 구조로 인식합니다. extends로 공통 필드를 뽑아내는 게 깔끔해 보여도, narrowing을 깨면 의미가 없으니까요.

정리

요약하면 이렇습니다.

  • discriminated union은 공통 판별자로 여러 종류를 묶고, 종류마다 다른 데이터를 타입에 새깁니다
  • 판별자로 분기하면 각 종류에 맞는 필드만 안전하게 꺼낼 수 있습니다(narrowing)
  • 종류가 늘 때 never 완전성 검사를 더하면, 처리 안 한 분기를 컴파일러가 전부 짚어 줍니다
  • 종류마다 데이터가 정말 다를 때 쓰고, 단순 토글이나 깨지는 판별자에는 굳이 쓰지 않습니다

결국 discriminated union은 “종류마다 다른 데이터”를 타입과 컴파일러에 맡기는 도구입니다. 그 조건이 맞는 자리에서 가장 깔끔하게 일합니다.


참고 자료