Discriminated Unions в TypeScript
Что такое Discriminated Unions?
Discriminated Unions (Размеченные объединения) — это паттерн в TypeScript, где каждый тип в union имеет общее свойство с литеральным типом, которое используется для различения (discriminating) типов.
Также известны как: Tagged Unions, Algebraic Data Types, Sum Types.
Базовый пример
type Circle = {
kind: 'circle';
radius: number;
};
type Square = {
kind: 'square';
size: number;
};
type Rectangle = {
kind: 'rectangle';
width: number;
height: number;
};
type Shape = Circle | Square | Rectangle;
Здесь kind — это discriminant (дискриминант), общее свойство с литеральными типами.
Использование с Type Narrowing
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript знает, что это Circle
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript знает, что это Square
return shape.size ** 2;
case 'rectangle':
// TypeScript знает, что это Rectangle
return shape.width * shape.height;
}
}
const circle: Circle = { kind: 'circle', radius: 5 };
console.log(getArea(circle)); // 78.53981633974483
TypeScript автоматически сужает тип в каждой ветке switch.
Зачем нужны Discriminated Unions?
Безопасность типов
// Без discriminated unions
type ShapeBad = {
radius?: number;
size?: number;
width?: number;
height?: number;
};
function getAreaBad(shape: ShapeBad): number {
if (shape.radius) {
return Math.PI * shape.radius ** 2;
}
if (shape.size) {
return shape.size ** 2;
}
if (shape.width && shape.height) {
return shape.width * shape.height;
}
return 0; // Что это?
}
// С discriminated unions - все явно и типобезопасно
Моделирование состояний
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: string;
};
type ErrorState = {
status: 'error';
error: string;
};
type State = LoadingState | SuccessState | ErrorState;
function renderUI(state: State) {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return `Data: ${state.data}`;
case 'error':
return `Error: ${state.error}`;
}
}
Exhaustiveness Checking
TypeScript может проверить, что мы обработали все возможные случаи.
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET'; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
return action.value;
default:
// Проверка на полноту обработки
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
Если добавить новый тип action, TypeScript выдаст ошибку:
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET'; value: number }
| { type: 'MULTIPLY'; factor: number }; // Новый тип
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
return action.value;
default:
// Ошибка: Type 'MULTIPLY' is not assignable to type 'never'
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
Практические паттерны
API Response
type ApiSuccess<T> = {
status: 'success';
data: T;
timestamp: number;
};
type ApiError = {
status: 'error';
message: string;
code: number;
};
type ApiResponse<T> = ApiSuccess<T> | ApiError;
async function fetchUser(id: number): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return {
status: 'success',
data,
timestamp: Date.now()
};
} catch (error) {
return {
status: 'error',
message: error.message,
code: 500
};
}
}
// Использование
const response = await fetchUser(1);
if (response.status === 'success') {
console.log(response.data); // User
} else {
console.error(response.message); // string
}
Формы и валидация
type FormIdle = {
state: 'idle';
};
type FormValidating = {
state: 'validating';
fieldName: string;
};
type FormValid = {
state: 'valid';
values: Record<string, any>;
};
type FormInvalid = {
state: 'invalid';
errors: Record<string, string>;
};
type FormState = FormIdle | FormValidating | FormValid | FormInvalid;
function FormComponent({ formState }: { formState: FormState }) {
switch (formState.state) {
case 'idle':
return <div>Fill out the form</div>;
case 'validating':
return <div>Validating {formState.fieldName}...</div>;
case 'valid':
return <div>Form is valid! {JSON.stringify(formState.values)}</div>;
case 'invalid':
return (
<div>
Errors:
{Object.entries(formState.errors).map(([field, error]) => (
<div key={field}>{field}: {error}</div>
))}
</div>
);
}
}
WebSocket сообщения
type ConnectedMessage = {
type: 'connected';
sessionId: string;
};
type MessageReceived = {
type: 'message';
content: string;
author: string;
timestamp: number;
};
type UserJoined = {
type: 'user_joined';
username: string;
};
type UserLeft = {
type: 'user_left';
username: string;
};
type DisconnectedMessage = {
type: 'disconnected';
reason: string;
};
type WebSocketMessage =
| ConnectedMessage
| MessageReceived
| UserJoined
| UserLeft
| DisconnectedMessage;
function handleMessage(message: WebSocketMessage) {
switch (message.type) {
case 'connected':
console.log(`Connected with session: ${message.sessionId}`);
break;
case 'message':
console.log(`${message.author}: ${message.content}`);
break;
case 'user_joined':
console.log(`${message.username} joined`);
break;
case 'user_left':
console.log(`${message.username} left`);
break;
case 'disconnected':
console.log(`Disconnected: ${message.reason}`);
break;
}
}
Redux-style Actions
type FetchUsersRequest = {
type: 'FETCH_USERS_REQUEST';
};
type FetchUsersSuccess = {
type: 'FETCH_USERS_SUCCESS';
payload: User[];
};
type FetchUsersFailure = {
type: 'FETCH_USERS_FAILURE';
error: string;
};
type Action = FetchUsersRequest | FetchUsersSuccess | FetchUsersFailure;
interface State {
users: User[];
loading: boolean;
error: string | null;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_USERS_REQUEST':
return {
...state,
loading: true,
error: null
};
case 'FETCH_USERS_SUCCESS':
return {
...state,
loading: false,
users: action.payload
};
case 'FETCH_USERS_FAILURE':
return {
...state,
loading: false,
error: action.error
};
}
}
Вложенные Discriminated Unions
type NetworkError = {
kind: 'network';
statusCode: number;
};
type ValidationError = {
kind: 'validation';
fieldErrors: Record<string, string>;
};
type AuthError = {
kind: 'auth';
reason: 'expired' | 'invalid';
};
type AppError = NetworkError | ValidationError | AuthError;
type SuccessResult<T> = {
status: 'success';
data: T;
};
type ErrorResult = {
status: 'error';
error: AppError;
};
type Result<T> = SuccessResult<T> | ErrorResult;
function handleResult<T>(result: Result<T>) {
if (result.status === 'success') {
console.log(result.data);
} else {
// Вложенное сужение
switch (result.error.kind) {
case 'network':
console.error(`Network error: ${result.error.statusCode}`);
break;
case 'validation':
console.error('Validation errors:', result.error.fieldErrors);
break;
case 'auth':
console.error(`Auth error: ${result.error.reason}`);
break;
}
}
}
Создание helper функций
// Action creators с типобезопасностью
type Action =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number };
// Функции-создатели действий
const addTodo = (text: string): Action => ({
type: 'ADD_TODO',
text
});
const toggleTodo = (id: number): Action => ({
type: 'TOGGLE_TODO',
id
});
const deleteTodo = (id: number): Action => ({
type: 'DELETE_TODO',
id
});
// Использование
const action = addTodo('Buy milk'); // type: Action
Извлечение типов из Discriminated Unions
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_VALUE'; value: number };
// Извлечь конкретный тип по дискриминанту
type ExtractAction<T extends Action['type']> = Extract<Action, { type: T }>;
type IncrementAction = ExtractAction<'INCREMENT'>;
// { type: 'INCREMENT' }
type SetValueAction = ExtractAction<'SET_VALUE'>;
// { type: 'SET_VALUE'; value: number }
// Извлечь payload
type ActionPayload<T extends Action['type']> = ExtractAction<T> extends { value: infer P }
? P
: never;
type SetValuePayload = ActionPayload<'SET_VALUE'>; // number
Типичные ошибки
Забыть про literal types
// Плохо - тип слишком широкий
type BadShape = {
kind: string; // Должен быть литерал!
radius: number;
};
// Хорошо
type GoodShape = {
kind: 'circle'; // Литеральный тип
radius: number;
};
Несогласованные дискриминанты
// Плохо - разные имена для дискриминанта
type Circle = { kind: 'circle'; radius: number };
type Square = { type: 'square'; size: number }; // type вместо kind!
// Хорошо - единое имя
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; size: number };
Опциональный дискриминант
// Плохо
type BadAction = {
type?: 'INCREMENT'; // Опциональный!
};
// Хорошо
type GoodAction = {
type: 'INCREMENT'; // Обязательный
};
Преимущества
- Типобезопасность во время компиляции
- Автоматическое сужение типов
- Exhaustiveness checking
- Читаемый и понятный код
- Нет необходимости в type assertions
- Легко расширять новыми случаями
- Отличная поддержка рефакторинга
Вывод
Discriminated Unions:
- Паттерн для моделирования взаимоисключающих состояний
- Требуют общее свойство с литеральными типами (дискриминант)
- Автоматическое сужение типов в
switchиif - Exhaustiveness checking через
never - Идеальны для состояний, API responses, actions
- Лучше optional properties для моделирования вариантов
На собеседовании:
Важно уметь:
- Объяснить, что такое discriminated unions и discriminant
- Показать пример с type narrowing
- Реализовать exhaustiveness checking
- Привести практические примеры (API, Redux actions, состояния)
- Объяснить преимущества перед optional properties
- Показать, как извлекать типы из union