Урок 06

Discriminated unions

Discriminated union — union объектов с общим полем-дискриминатором. Мощный паттерн для представления состояний и вариантов.

Пример

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

Поле kind — общее для всех вариантов, но с разными значениями. Это дискриминатор.

Использование

function area(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;
    }
}

В каждом case TypeScript знает точный вариант и даёт доступ к его полям.

Практика: состояния запроса

type RequestState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };
function render(state: RequestState<User>) {
    switch (state.status) {
        case 'idle':
            return <p>Начните поиск</p>;
        case 'loading':
            return <Spinner />;
        case 'success':
            return <UserCard user={state.data} />;
        case 'error':
            return <Error message={state.error.message} />;
    }
}

Невозможные состояния не выразить: нельзя быть loading и success одновременно.

Exhaustive check

never проверяет, что все варианты обработаны:

function area(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:
            const _exhaustive: never = shape;
            throw new Error('Unknown shape');
    }
}

Если добавить новый вариант в Shape и забыть обработать — TypeScript выдаст ошибку на never.

С дискриминатором-числом

type Event =
    | { type: 1; payload: string }
    | { type: 2; payload: number };

Обычно используют строки — читаемее.

С вложенными

type Notification =
    | { kind: 'email'; address: string; subject: string }
    | { kind: 'sms'; phone: string; text: string }
    | { kind: 'push'; deviceId: string; title: string; body: string };

В React

type Props =
    | { variant: 'text'; children: string }
    | { variant: 'icon'; icon: React.ReactNode }
    | { variant: 'link'; href: string; children: string };

function Button(props: Props) {
    if (props.variant === 'text') {
        return <button>{props.children}</button>;
    }
    if (props.variant === 'icon') {
        return <button>{props.icon}</button>;
    }
    return <a href={props.href}>{props.children}</a>;
}

Reducer

Discriminated unions идеально подходят для редьюсеров:

type Action =
    | { type: 'increment' }
    | { type: 'decrement' }
    | { type: 'set'; value: number };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case 'increment': return state + 1;
        case 'decrement': return state - 1;
        case 'set': return action.value;
    }
}

Плюсы

  • Невозможные состояния не компилируются
  • Все варианты обработаны
  • Точные поля в каждом варианте
  • Легко добавлять новые

Итоги

  • Дискриминатор — общее поле
  • switch по дискриминатору сужает тип
  • never для exhaustive check
  • Идеально для состояний запроса
  • Стандарт для редьюсеров