Коротко: Как использовать Discriminated Unions в React и TypeScript: избавляемся от булевых флагов, предотвращаем UI-баги и настраиваем исчерпывающую проверку стейта.
Каждый фронтенд-разработчик сталкивался с ситуацией, когда UI-компонент одновременно отображает спиннер загрузки, старые данные и плашку с ошибкой. Причина такого поведения кроется в архитектурной ошибке моделирования состояния: независимые булевые флаги и разрозненные переменные допускают комбинации, которых в бизнес-логике существовать не должно.
В проектировании типов есть фундаментальный принцип: Make invalid states unrepresentable (делайте невалидные состояния непредставимыми). Лучший инструмент для реализации этого принципа в связке TypeScript и React — размеченные объединения (Discriminated Unions). Они переносят валидацию логики с этапа выполнения (runtime) на этап компиляции, устраняя скрытые дефекты интерфейса еще до первого запуска тестов.
Анатомия проблемы: почему независимые useState плодят баги
Рассмотрим классический шаблон компонента, загружающего данные из API. Большинство разработчиков начинают с набора независимых хуков useState.
// Типичный антипаттерн
const UserProfile = ({ userId }: { userId: string }) => {
const [data, setData] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fetchUser = async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.getUser(userId);
setData(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
// ...
};
Коллизии состояний и рассинхронизация UI (Ghost States)
На первый взгляд код выглядит привычно, но он создает $2^3 = 8$ теоретически возможных комбинаций состояния. При этом бизнес-логика валидирует только 4:
- Начальное состояние (
idle): нет данных, нет загрузки, нет ошибки. - Загрузка (
loading): идет запрос, данных нет, ошибки нет. - Успех (
success): данные есть, загрузки нет, ошибки нет. - Ошибка (
error): данных нет, загрузки нет, есть сообщение об ошибке.
Остальные 4 комбинации — это «фантомные состояния» (Ghost States):
isLoading: trueиerror: "Network Error"одновременно.isLoading: false,data: null,error: nullпри перезапросе.data: Userиerror: "Forbidden"одновременно (например, если забыли очистить ошибку перед установкой новых данных).
Когда компонент разрастается, разработчикам приходится писать монструозные конструкции рендеринга:
// Хрупкая цепочка тернарных операторов
return (
<div>
{isLoading && <Spinner />}
{!isLoading && error && <ErrorMessage text={error} />}
{!isLoading && !error && data && <UserDetails user={data} />}
{!isLoading && !error && !data && <EmptyPlaceholder />}
</div>
);
Любая ошибка в порядке условий или пропущенный флаг в set-функции приводит к мерцанию интерфейса или отрисовке некорректных экранов.
Что такое Discriminated Unions в TypeScript и как работает сужение типов
Discriminated Union (размеченное, тегированное объединение) — это объединение объектных типов, каждый из которых содержит общее поле с литеральным типом. Это поле выступает в роли метки (дискриминанта, тега).
Структура размеченного объединения
Дискриминантом обычно служит поле status, type или kind. Значения должны быть строковыми или числовыми литералами, а не общим типом string.
type IdleState = {
status: 'idle';
};
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: User;
};
type ErrorState = {
status: 'error';
error: string;
};
// Размеченное объединение
type UserState = IdleState | LoadingState | SuccessState | ErrorState;
Механизм Type Narrowing (сужение типов)
Компилятор TypeScript понимает связь между литеральным полем и структурой объекта. Как только вы проверяете значение status через if или switch, TypeScript автоматически отсекает неподходящие типы.
const renderContent = (state: UserState) => {
if (state.status === 'success') {
// Внутри этого блока TypeScript гарантирует:
// 1. Поле state.data существует и имеет тип User
// 2. Поля state.error здесь не существует
return <UserDetails user={state.data} />;
}
if (state.status === 'error') {
// Здесь state.error — строка, а state.data недоступно
return <ErrorMessage text={state.error} />;
}
};
Попытка обратиться к state.data вне ветки 'success' вызовет ошибку на этапе компиляции. Невалидные комбинации данных исключаются на уровне системы типов.
Рефакторинг состояния React: от разрозненных флагов к единой модели
Объединим разрозненные useState в один атомарный стейт с использованием Discriminated Union.
import React, { useState, useEffect } from 'react';
type User = {
id: string;
name: string;
email: string;
};
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
export const UserCard = ({ userId }: { userId: string }) => {
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });
useEffect(() => {
let isMounted = true;
setState({ status: 'loading' });
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error('Ошибка при загрузке данных');
return res.json() as Promise<User>;
})
.then((data) => {
if (isMounted) {
setState({ status: 'success', data });
}
})
.catch((err: unknown) => {
if (isMounted) {
setState({
status: 'error',
error: err instanceof Error ? err.message : 'Неизвестная ошибка',
});
}
});
return () => {
isMounted = false;
};
}, [userId]);
switch (state.status) {
case 'idle':
return <p>Ожидание запуска...</p>;
case 'loading':
return <Spinner />;
case 'error':
return <ErrorMessage text={state.error} />;
case 'success':
return <UserDetails user={state.data} />;
}
};
Преимущества такого подхода:
- Атомарные апдейты: Невозможно изменить статус, забыв сбросить ошибку или данные.
- Чистый JSX: Логика отображения сводится к прямому маппингу вариантов через
switch. - Строгий контракт: Поле
dataсуществует только приstatus: 'success'.
Интеграция с useReducer для детерминированных переходов
Если компонент содержит сложную логику переходов (например, повторные запросы, пагинацию или сохранение предыдущих данных при фоновом обновлении), связка Discriminated Unions и хука useReducer работает как легковесный конечный автомат (Finite State Machine).
В useReducer объединения используются на двух уровнях: для состояния (State) и для действий (Action).
// Описание возможных действий (Actions)
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User }
| { type: 'FETCH_FAILURE'; payload: string }
| { type: 'RESET' };
// Редуктор с гарантией валидных переходов
const userReducer = (state: AsyncState<User>, action: Action): AsyncState<User> => {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload };
case 'FETCH_FAILURE':
return { status: 'error', error: action.payload };
case 'RESET':
return { status: 'idle' };
default:
return state;
}
};
Такая структура исключает возникновение промежуточных «поломанных» состояний при любых действиях пользователя или асинхронных ответах.
Исчерпывающая проверка (Exhaustiveness Checking) с типом never
При активной разработке состав объединения периодически расширяется. Например, к статусам может добавиться 'empty' (когда запрос выполнен успешно, но список элементов пуст) или 'revalidating'.
Если разработчик добавит новый вариант в union-тип, но забудет обработать его в блоке switch, интерфейс может вернуть undefined или проигнорировать отрисовку.
Для защиты от таких сценариев используется исчерпывающая проверка через тип never.
Реализация утилиты assertNever
export function assertNever(x: never): never {
throw new Error(`Необработанный вариант объединения: ${JSON.stringify(x)}`);
}
Применение в компоненте
type ExtendedState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'empty' }
| { status: 'error'; error: string };
const renderView = (state: ExtendedState<User[]>) => {
switch (state.status) {
case 'idle':
return <Placeholder />;
case 'loading':
return <Spinner />;
case 'success':
return <UserList items={state.data} />;
case 'empty':
return <EmptyView />;
case 'error':
return <ErrorBanner message={state.error} />;
default:
// Если мы добавим новый статус и не создадим для него case,
// TypeScript выдаст ошибку компиляции именно на этой строке:
// Argument of type 'NewState' is not assignable to parameter of type 'never'
return assertNever(state);
}
};
Благодаря assertNever рефакторинг становится безопасным: компилятор сам подсвечивает все места в кодовой базе, где новый вариант еще не обработан.
Взаимоисключающие пропсы: типизация компонентов через размеченные объединения
Паттерн применим не только к стейту, но и к пропсам компонентов. Классический пример — модальные окна, кнопки и поля ввода, поведение которых кардинально меняется в зависимости от режима.
Представим компонент кнопки Button, которая может быть либо ссылкой (требует href), либо обычной кнопкой (требует onClick), либо кнопкой отправки формы.
Антипаттерн: опциональные пропсы
// Плохо: можно передать href и onClick одновременно, или не передать ничего
type BadButtonProps = {
as?: 'button' | 'link';
href?: string;
onClick?: () => void;
children: React.ReactNode;
};
Решение через Discriminated Unions
type BaseButtonProps = {
variant?: 'primary' | 'secondary';
children: React.ReactNode;
};
type LinkButtonProps = BaseButtonProps & {
as: 'link';
href: string;
target?: string;
onClick?: never;
};
type ActionButtonProps = BaseButtonProps & {
as: 'button';
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
href?: never;
};
type SubmitButtonProps = BaseButtonProps & {
as: 'submit';
disabled?: boolean;
onClick?: never;
href?: never;
};
type ButtonProps = LinkButtonProps | ActionButtonProps | SubmitButtonProps;
export const Button = (props: ButtonProps) => {
if (props.as === 'link') {
return (
<a href={props.href} target={props.target} className={props.variant}>
{props.children}
</a>
);
}
if (props.as === 'submit') {
return (
<button type="submit" disabled={props.disabled} className={props.variant}>
{props.children}
</button>
);
}
return (
<button type="button" onClick={props.onClick} disabled={props.disabled} className={props.variant}>
{props.children}
</button>
);
};
Теперь IDE выдаст ошибку еще на этапе набора кода, если вы попытаетесь передать href в кнопку с as="button".
Когда Discriminated Unions незаменимы, а когда избыточны
Размеченные объединения — мощный инструмент, но его применение должно быть осознанным.
Незаменимы, когда:
- Состояние содержит взаимоисключающие наборы данных (
datavserror). - Рендеринг зависит от дискретных шагов процесса (визарды оформления заказа, многофакторная аутентификация).
- Логика компонента содержит больше двух связанных булевых флагов.
- Требуется жесткий контракт для вариативных UI-компонентов.
Избыточны, когда:
- Компонент управляет независимыми элементами управления (например, состояние открытости дропдауна
isOpen: booleanникак не связано со значением текстового поля). - Вы работаете с простыми контролируемыми полями форм (для них достаточно обычных примитивов или специализированных библиотек вроде React Hook Form).
FAQ (Часто задаваемые вопросы)
1. Чем Discriminated Union отличается от обычного Union (TypeA | TypeB)?
В обычном объединении (TypeA | TypeB) у типов может не быть общего однозначного идентификатора. Для их различения приходится использовать оператор in ('data' in state), instanceof или пользовательские Type Guards (isUser(obj)). В Discriminated Union всегда присутствует общее поле с литеральным типом (дискриминант), по которому компилятор TypeScript мгновенно и нативно выполняет сужение без лишних проверок в рантайме.
2. Как Discriminated Unions сочетаются с библиотеками серверного стейта (TanStack Query, RTK Query)?
Библиотеки вроде TanStack Query (React Query) внутри себя реализуют похожий подход, предоставляя статус запроса (status: 'pending' | 'error' | 'success'). Однако при создании собственных хуков-оберток или нормализации данных под доменную логику явное приведение ответа к Discriminated Union позволяет инкапсулировать работу с кэшем и защитить дочерние компоненты от неполных данных.
3. Можно ли использовать enum вместо строковых литералов в качестве дискриминанта?
Технически можно, но строковые литеральные объединения ('idle' | 'loading') предпочтительнее. Они не генерируют лишний JavaScript-код при компиляции, лучше сериализуются, прозрачно выводятся в логах и сообщениях об ошибках, а также проще комбинируются без необходимости импортировать объект enum во все файлы.
4. Как правильно типизировать сброс состояния к начальному (idle)?
Для сброса достаточно передать объект, соответствующий начальной ветке объединения. Если вы используете useState:
const handleReset = () => {
setState({ status: 'idle' });
};
Поскольку поле status: 'idle' не требует наличия data или error, стейт возвращается в исходное валидное состояние без необходимости очищать каждую переменную по отдельности.
5. Как сохранить предыдущие данные при обновлении (Background Revalidation)?
Если необходимо показывать старые данные во время загрузки новых (паттерн Stale-While-Revalidate), расширьте тип загрузки:
type AsyncStateWithKeepPrevious<T> =
| { status: 'idle' }
| { status: 'loading'; previousData?: T }
| { status: 'success'; data: T }
| { status: 'error'; error: string; previousData?: T };
Это позволит безопасно рендерить фоновые спиннеры поверх существующего контента.
Вывод
Использование независимых булевых переменных для контроля UI-состояний — частый источник техдолга и багов. Переход на Discriminated Unions позволяет:
- Полностью устранить невалидные комбинации данных в компонентах.
- Упростить чтение и поддержку JSX-разметки за счет отказа от цепочек тернарных операторов.
- Получить надежную защиту компилятора при рефакторинге через исчерпывающую проверку (
never).
Моделируйте состояние как конечный набор строго определенных вариантов — это сделает код предсказуемым, тестируемым и устойчивым к изменениям требований.




.svg.webp)



