← Курс/Discriminated Unions: теговые объединения#152 из 257+25 XP

Discriminated Unions: теговые объединения

Что такое Discriminated Unions

Discriminated Union (теговый union, tagged union) — это паттерн, где несколько типов объединяются через общее **дискриминирующее поле** (обычно называемое kind, type, tag), которое содержит уникальный литеральный тип для каждого варианта.

// Без discriminated union — сложно различить варианты:
interface CircleOrRect {
  radius?: number     // только для круга
  width?: number      // только для прямоугольника
  height?: number     // только для прямоугольника
}
// Как понять что перед нами?

// С discriminated union — чисто и безопасно:
interface Circle {
  kind: 'circle'   // дискриминирующее поле
  radius: number
}

interface Rectangle {
  kind: 'rectangle'  // дискриминирующее поле
  width: number
  height: number
}

type Shape = Circle | Rectangle

Narrowing через switch/if

TypeScript автоматически сужает тип в ветках switch или if по дискриминирующему полю:

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // Здесь TypeScript знает: shape: Circle
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      // Здесь TypeScript знает: shape: Rectangle
      return shape.width * shape.height
  }
}

// Работает и с if:
if (shape.kind === 'circle') {
  shape.radius  // OK — TypeScript знает что это Circle
}

Exhaustive checks с never

Добавление default ветки с never гарантирует что при добавлении нового типа в union TypeScript выдаст ошибку:

type Shape = Circle | Rectangle | Triangle

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':    return Math.PI * shape.radius ** 2
    case 'rectangle': return shape.width * shape.height
    // Если забыть 'triangle' — TypeScript выдаст ошибку:
    default:
      const exhaustiveCheck: never = shape
      // Ошибка: Type 'Triangle' is not assignable to type 'never'
      throw new Error(`Неизвестная фигура: ${(exhaustiveCheck as any).kind}`)
  }
}

Паттерн: State Machine

Discriminated unions идеальны для состояний в UI:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

function renderState(state: RequestState<User[]>): string {
  switch (state.status) {
    case 'idle':    return 'Нажмите для загрузки'
    case 'loading': return 'Загрузка...'
    case 'success': return `Найдено ${state.data.length} пользователей`
    case 'error':   return `Ошибка: ${state.error}`
  }
}

Паттерн: Action/Command

Discriminated unions используются в Redux-подобных паттернах:

type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' }
  | { type: 'SET_VALUE'; 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 0
    case 'SET_VALUE':  return action.payload  // TypeScript знает: action.payload существует
  }
}

Преимущества паттерна

1. **Безопасный доступ к полям** — TypeScript не даст обратиться к radius у прямоугольника

2. **Exhaustive checks** — TS предупредит если не обработали все варианты

3. **Читаемость** — поле kind/type явно документирует вариант

4. **Рефакторинг** — при добавлении нового варианта TypeScript найдёт все места для обновления

Примеры

Discriminated unions: паттерн с kind-полем и исчерпывающие проверки

// В TS: discriminated union с полем kind/type
// В JS: тот же паттерн, но без compile-time проверок

// === Фигуры с discriminated union ===
function getArea(shape) {
  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:
      // В TS: const check: never = shape — ошибка если добавить новый тип
      throw new Error(`Неизвестная фигура: ${shape.kind}`)
  }
}

function describeShape(shape) {
  switch (shape.kind) {
    case 'circle':    return `Круг с радиусом ${shape.radius}`
    case 'rectangle': return `Прямоугольник ${shape.width}×${shape.height}`
    case 'triangle':  return `Треугольник: основание ${shape.base}, высота ${shape.height}`
    default:
      throw new Error(`Неизвестная фигура: ${shape.kind}`)
  }
}

const shapes = [
  { kind: 'circle', radius: 5 },
  { kind: 'rectangle', width: 4, height: 6 },
  { kind: 'triangle', base: 3, height: 8 },
]

console.log('=== Фигуры ===')
shapes.forEach(shape => {
  console.log(`${describeShape(shape)} — площадь: ${getArea(shape).toFixed(2)}`)
})

// === Паттерн: State Machine для загрузки данных ===
console.log('\n=== Request State Machine ===')

function renderState(state) {
  // В TS: state: RequestState<User[]>
  // TypeScript через discriminated union знает какие поля доступны в каждом case
  switch (state.status) {
    case 'idle':    return 'Нажмите для загрузки'
    case 'loading': return 'Загрузка данных...'
    case 'success': return `Загружено ${state.data.length} записей: ${state.data.join(', ')}`
    case 'error':   return `Ошибка: ${state.error}`
    default:
      throw new Error(`Неизвестный статус: ${state.status}`)
  }
}

const states = [
  { status: 'idle' },
  { status: 'loading' },
  { status: 'success', data: ['Алексей', 'Мария', 'Дмитрий'] },
  { status: 'error', error: 'Сервер недоступен' },
]

states.forEach(state => {
  console.log(renderState(state))
})

// === Redux-like reducer с discriminated union ===
console.log('\n=== Reducer (Action pattern) ===')

function counterReducer(state, action) {
  // В TS: action: Action — discriminated union по полю type
  switch (action.type) {
    case 'INCREMENT':  return state + 1
    case 'DECREMENT':  return state - 1
    case 'RESET':      return 0
    case 'SET_VALUE':  return action.payload  // только у SET_VALUE есть payload
    default:
      throw new Error(`Неизвестный action: ${action.type}`)
  }
}

let count = 0
const actions = [
  { type: 'INCREMENT' },
  { type: 'INCREMENT' },
  { type: 'INCREMENT' },
  { type: 'DECREMENT' },
  { type: 'SET_VALUE', payload: 10 },
  { type: 'INCREMENT' },
]

actions.forEach(action => {
  count = counterReducer(count, action)
  console.log(`${action.type}: ${count}`)
})