---
id: tagged-union-discriminated-types
title: 태그드 유니언 / Discriminated Types
category: Coding
status: draft
canonical_id: tagged-union-discriminated-types
aliases: [discriminated union, sum type, ADT, sealed class, variant type, 태그드 유니온]
duplicate_of: null
source_trust_level: B
confidence_score: 0.9
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
last_reinforced: 2026-05-09
review_reason: ""
merge_history: []
tags: [coding, type-safety, modeling, state-machine, vibe-coding]
raw_sources: ["P-Reinforce session 2026-05-09 — bulk Coding seed batch 1"]
tech_stack:
  language: "TypeScript / Rust / Kotlin / Swift / Haskell"
  applicable_to: ["Domain modeling", "State machines", "API contracts"]
applied_in: []
---

# 태그드 유니언 / Discriminated Types

> "이 객체는 A 일 수도 있고 B 일 수도 있다" 를 boolean 플래그가 아니라 **`kind` 필드로 명시**. 컴파일러가 모든 분기 처리를 강제하게 만든다.

## 📖 핵심 개념

여러 상태 / 모양을 가질 수 있는 데이터를 모델링하는 두 가지 방식:

- **잘못된 방식**: optional 필드를 잔뜩 쌓아 "이 조합일 때만 이게 의미 있어요" 라고 주석으로 설명.
- **올바른 방식**: 각 상태를 별도 type 으로 정의하고 `kind` (또는 `type`, `_tag`) 필드로 구분.

장점:
- 잘못된 조합 자체가 표현 불가능 (illegal states unrepresentable)
- 컴파일러가 exhaustive switch 강제 → 새 case 추가 시 자동으로 모든 분기 점검
- IDE 가 분기 안에서 타입 자동 좁힘

## 💻 코드 패턴

### TypeScript discriminated union

```ts
// ❌ 잘못된 모델 — 잘못된 조합 가능
type LoadingState = {
  isLoading: boolean;
  data?: User;
  error?: string;
};
// 다음과 같은 잘못된 상태가 표현됨:
// { isLoading: true, data: user, error: 'oops' }   // 다 채워짐?
// { isLoading: false }                             // 데이터도 에러도 없음?

// ✅ 태그드 유니언
type LoadingState =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: User }
  | { kind: 'error'; message: string };

// 잘못된 상태는 표현 불가능
```

### Exhaustive switch

```ts
function render(state: LoadingState): string {
  switch (state.kind) {
    case 'idle':    return '대기 중';
    case 'loading': return '로딩...';
    case 'success': return `안녕, ${state.data.name}`; // ✓ data 존재 보장
    case 'error':   return `오류: ${state.message}`;   // ✓ message 존재 보장
  }
}
```

새로운 `kind` 를 union 에 추가하면 컴파일러가 위 switch 의 누락을 잡음. **`never` 트릭** 으로 강제:

```ts
function assertNever(x: never): never {
  throw new Error(`unhandled case: ${JSON.stringify(x)}`);
}

function render(state: LoadingState): string {
  switch (state.kind) {
    case 'idle':    return '대기 중';
    case 'loading': return '로딩...';
    case 'success': return `안녕, ${state.data.name}`;
    case 'error':   return `오류: ${state.message}`;
    default:        return assertNever(state); // 새 kind 추가하면 컴파일 에러
  }
}
```

### 도메인 모델링 — 결제 상태

```ts
type Payment =
  | { kind: 'pending'; amount: Money }
  | { kind: 'authorized'; amount: Money; authCode: string }
  | { kind: 'captured'; amount: Money; authCode: string; capturedAt: Date }
  | { kind: 'refunded'; original: Money; refunded: Money; reason: string }
  | { kind: 'failed'; amount: Money; reason: 'CARD_DECLINED' | 'NETWORK' | 'FRAUD' };

// state transition
function capture(p: Payment, now: Date): Payment {
  if (p.kind !== 'authorized') throw new Error(`Cannot capture in state ${p.kind}`);
  return { kind: 'captured', amount: p.amount, authCode: p.authCode, capturedAt: now };
}
```

`pending` 상태에는 `authCode` 가 없고 `captured` 에는 있다. 컴파일러가 강제. 잘못된 transition 도 명시적 거부.

### Rust 스타일 (참고)

```rust
enum Payment {
  Pending { amount: Money },
  Authorized { amount: Money, auth_code: String },
  Captured { amount: Money, auth_code: String, captured_at: DateTime },
  Failed { amount: Money, reason: FailReason },
}

match payment {
  Payment::Pending { amount } => ...,
  Payment::Authorized { auth_code, .. } => ...,
  // 누락 시 컴파일 에러
}
```

### Kotlin sealed class

```kotlin
sealed class LoadingState {
  object Idle : LoadingState()
  object Loading : LoadingState()
  data class Success(val data: User) : LoadingState()
  data class Error(val message: String) : LoadingState()
}

when (state) {
  is LoadingState.Idle -> ...
  is LoadingState.Loading -> ...
  is LoadingState.Success -> state.data // 자동 좁힘
  is LoadingState.Error -> state.message
} // sealed → exhaustive 검증
```

## 🤔 의사결정 기준

| 상황 | Tagged Union | Optional 필드 plain object |
|---|---|---|
| 상태 수 2-10개 | ✅ | ❌ |
| 각 상태마다 다른 데이터 | ✅ | ❌ |
| 모든 상태에 같은 필드 (값만 다름) | 단순 enum 으로 충분 | — |
| 상태 전이 (state machine) | ✅ + 함수형 transition | ❌ |
| API 응답 (success / error / partial) | ✅ | ❌ |
| 트리 / AST 노드 | ✅ | ❌ |

## ❌ 안티패턴

- **`type` 또는 `kind` 필드 없는 union**: `User | Admin` 만으로는 좁히기 어려움. 공통 필드 검증 또는 instanceof 필요. discriminator 추가 권장.
- **discriminator 가 string 이 아닌 boolean**: 분기 2개로만 제한. 3번째 상태 추가하기 어려움.
- **default case 에서 throw 하지 않고 무시**: 새 kind 추가 시 silently 무시. assertNever 패턴 권장.
- **switch 가 아니라 if/else if 체인**: TypeScript 가 좁히기 잘 못 함. switch 로.
- **각 case 마다 동일한 helper 호출 반복**: 공통 로직은 union 밖으로 추출.
- **kind 필드를 동적으로 변경**: `state.kind = 'success'; state.data = user;` — 컴파일러가 좁힌 타입과 어긋남. 항상 새 객체 반환.
- **API 응답을 그대로 도메인에 사용**: 외부 API 의 nullable 합성 응답을 도메인 깊숙이. 경계에서 zod 같은 schema 로 정규화 후 tagged union 으로.

## 🤖 LLM 활용 힌트

- LLM에게 상태 모델링: "**boolean / optional 필드 조합 대신 discriminated union 으로**" 명시.
- 새 case 추가 시: "**모든 switch / when 의 분기 같이 갱신**" 요청. assertNever 가 있으면 자동 검출.
- 외부 API 응답 파싱: "**zod / io-ts 로 schema 작성 후 discriminated union 으로 도메인 변환**" 패턴 요청.
- Reducer / state machine 작성: "**state 와 action 모두 discriminated union, transition 은 (state, action) → state 함수**" 명시.

## 🧪 검증 상태

- verification_status: `conceptual`
- Algebraic Data Types (ADT) 의 sum type — Haskell, ML, Rust, Swift, Kotlin sealed class, TS discriminated union 의 공통 개념.
- 적용 사례 발견 시 `applied_in` 추가.

## 🔗 관련 문서

- [[Error_Handling_Result_vs_Throw]]
- [[Null_Safety_Patterns]]
- [[Pure_Functions_in_Practice]]
