Discriminated Unions

Discriminated Unions (размеченные объединения) — паттерн, при котором каждый вариант union-типа содержит общее литеральное поле-дискриминатор (обычно kind, type, status), позволяющее TypeScript точно сужать тип в switch/if без дополнительных type guard.

Зачем нужно

При работе с union-типами TypeScript должен знать, какой именно вариант перед ним. Discriminated union решает это элегантно: одно общее поле с литеральным типом однозначно идентифицирует вариант, и компилятор автоматически сужает тип в ветках switch. Это основной паттерн моделирования состояний и результатов в TS.

Где используется

  • Моделирование состояний: loading | success | error
  • Паттерн Result/Either: { ok: true; data: T } | { ok: false; error: Error }
  • Redux-подобные action-типы
  • AST-узлы, парсеры, компиляторы
  • Event-типы в event-driven архитектуре

Основной контент

Базовый пример

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // shape: { kind: "circle"; radius: number }
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // TypeScript предупредит если пропустить вариант (с exhaustive check)
  }
}

Exhaustive check через never

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

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.radius ** 2;
    case "square":    return shape.side ** 2;
    case "rectangle": return shape.width * shape.height;
    default:
      return assertNever(shape); // Error если добавили вариант и забыли case
  }
}

Паттерн Result

type Result<T, E = Error> =
  | { ok: true; data: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error("Not found") };
    return { ok: true, data: await res.json() };
  } catch (e) {
    return { ok: false, error: e as Error };
  }
}

const result = await fetchUser("u-1");
if (result.ok) {
  console.log(result.data.name); // TypeScript знает: data есть
} else {
  console.error(result.error.message); // TypeScript знает: error есть
}

Состояния загрузки

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function render<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case "idle":    return "Ожидание...";
    case "loading": return "Загрузка...";
    case "success": return `Данные: ${JSON.stringify(state.data)}`;
    case "error":   return `Ошибка: ${state.message}`;
  }
}

Частые ошибки

  • Дискриминатор не литеральныйkind: string не даст narrowing; поле должно быть "circle" | "square", а не просто string.
  • Разные имена дискриминатора — все варианты должны использовать одно поле (kind, не type в одном и kind в другом).
  • Забыть exhaustive check — без assertNever в default добавление нового варианта не вызовет ошибку компиляции.
  • Мутировать дискриминатор — изменение поля kind в рантайме ломает логику TypeScript.

Связанные темы

Ресурсы