Система типов как инструмент мышления: union, narrowing и дискриминируемые объединения в TypeScript
На реальных примерах разберём, как проектировать безопасные модели данных и извлекать пользу из narrowing вместо кастов.
Содержание
Система типов как инструмент мышления: union, narrowing и дискриминируемые объединения в TypeScript
TypeScript часто воспринимают как «более строгий JavaScript». На практике же его система типов — это способ заставить программу думать вместе с вами. Когда модель данных описана корректно, вы меньше полагаетесь на касты, больше — на инварианты, и код начинает защищать вас от целых классов ошибок: неправильных форматов, забытых веток обработки и «невозможных» состояний.
В этой статье разберём, как проектировать безопасные модели данных с помощью:
union-типов (A | B)narrowing(сужение типа) вместо приведения (as)- дискриминируемых объединений (
discriminated union) — самого практичного паттерна для TypeScript
Будем опираться на реальные примеры: от описания событий до безопасной бизнес-логики. Цель — чтобы вы научились не «обходить» типы, а извлекать из них пользу.
Union-тип: почему это больше, чем “разрешить два варианта”
Union-тип в TypeScript означает: значение может быть одного из типов. Например:
type UserId = number | string;
function formatUser(id: UserId) {
return `id=${id}`;
}
Здесь всё просто: функция принимает значение, которое может быть number или string. Но ключевая проблема возникает почти сразу: как работать с id, если вы не знаете конкретный вариант?
TypeScript заставляет вас думать: если вы делаете операцию, допустимую только для number, вы должны сначала сузить тип до number.
type UserId = number | string;
function doubleId(id: UserId) {
// ❌ id может быть string — нельзя умножать без narrowing
return id * 2;
}
Типизация здесь — не «помеха», а сигнал: вы должны описать правила, когда именно id окажется числом, и обработать обе ветки.
Неправильные подходы: касты и почему они ломают “мышление типами”
Частая ошибка — использовать касты ради удобства. Например:
type ApiResponse = { kind: "ok"; value: number } | { kind: "error"; message: string };
function getValueUnsafe(r: ApiResponse) {
// ❌ Приведение “вслепую”: если r - error, будет логическая ошибка.
return (r as { kind: "ok"; value: number }).value;
}
Да, код скомпилируется. Но компилятор перестаёт быть инструментом контроля. Вы фактически сказали: «поверь мне, я знаю». Однако задача типовой системы — помогать вам не полагаться на внутреннее знание, которое легко потерять при рефакторинге.
Типы должны помогать извлекать инварианты из данных, а не заменять реальный анализ.
Narrowing: как TypeScript сужает тип до конкретного варианта
Narrowing — механизм, который уменьшает множество возможных типов в конкретной ветке кода. Это достигается через проверки, которые компилятор понимает.
1) typeof и instanceof
Для простых случаев:
type Payload = string | number;
function stringify(p: Payload) {
if (typeof p === "number") {
return p.toFixed(2); // p теперь number
}
return p.toUpperCase(); // p теперь string
}
2) Проверки полей в объектных unions
type Result =
| { ok: true; data: string }
| { ok: false; error: Error };
function describeResult(r: Result) {
if (r.ok) {
return `data length=${r.data.length}`;
}
return `error=${r.error.message}`;
}
Здесь компилятор распознаёт, что r.ok — это литеральный флаг (true | false), и сужает тип соответственно.
3) in для определения наличия поля
type Event =
| { type: "click"; x: number; y: number }
| { type: "scroll"; delta: number };
function payloadSummary(e: Event) {
if ("x" in e) {
return `click at (${e.x}, ${e.y})`;
}
return `scroll delta=${e.delta}`;
}
Ветвь с "x" in e сужает union так, что e становится только тем вариантом, где поле x существует.
Дискриминируемые объединения: практичный паттерн для “правильного” narrowing
Самый мощный и удобный подход — дискриминируемые объединения: union, где каждый вариант содержит общий литеральный дискриминатор (например, type или kind) с уникальными значениями.
Базовая модель: события
Рассмотрим события из фронтенда/бэкенда:
type ClickEvent = {
kind: "click";
x: number;
y: number;
};
type ScrollEvent = {
kind: "scroll";
delta: number;
};
type Event = ClickEvent | ScrollEvent;
function formatEvent(e: Event) {
// Простейшее narrowing по дискриминатору:
switch (e.kind) {
case "click":
return `click at (${e.x}, ${e.y})`;
case "scroll":
return `scroll delta=${e.delta}`;
default: {
// Здесь TypeScript должен помочь поймать “неучтённые” варианты
const _exhaustive: never = e;
return _exhaustive;
}
}
}
Важная деталь: блок default с never превращает обработку union в исчерпывающую. Если вы добавите новый вариант в тип Event, компилятор потребует обновить switch.
Этот паттерн — один из главных аргументов в пользу discriminated unions. Он превращает типы в “страховочную сетку” при развитии системы.
Зачем never в “исчерпывающих” switch и как его использовать правильно
Иногда union описан корректно, но вы забываете обработать ветку. Тогда код становится непредсказуемым, и баг уходит в рантайм.
Шаблон:
type A = { kind: "a"; a: number };
type B = { kind: "b"; b: string };
type U = A | B;
function handle(u: U) {
switch (u.kind) {
case "a":
return u.a * 2;
case "b":
return u.b.toUpperCase();
default: {
const exhaustive: never = u;
return exhaustive;
}
}
}
Если U изменится (например, появится { kind: "c"; ... }), компилятор покажет ошибку в месте, где u не может быть never.
Проектирование моделей данных: как выбирать структуру union
Union помогает не только при “обработке событий”, но и при моделировании состояния (state machine), результатов запросов, вариантов API.
1) Результаты запросов: loading | success | error
Пусть у нас есть хранилище состояния:
type LoadingState = { kind: "loading" };
type SuccessState = { kind: "success"; data: string[] };
type ErrorState = { kind: "error"; message: string };
type State = LoadingState | SuccessState | ErrorState;
function render(state: State) {
switch (state.kind) {
case "loading":
return "Loading...";
case "success":
return `Items: ${state.data.length}`;
case "error":
return `Error: ${state.message}`;
default: {
const _never: never = state;
return _never;
}
}
}
Здесь union описывает “что разрешено” в каждой фазе. Например, в состоянии loading не может быть data. Тип фиксирует это.
2) Описание команд/операций
Обычно безопаснее моделировать операции как данные, а выполнение — как обработчик:
type AddTodo = { kind: "add"; text: string };
type ToggleTodo = { kind: "toggle"; id: number };
type DeleteTodo = { kind: "delete"; id: number };
type Action = AddTodo | ToggleTodo | DeleteTodo;
function validate(action: Action): string | null {
switch (action.kind) {
case "add":
if (action.text.trim().length === 0) return "Text cannot be empty";
return null;
case "toggle":
case "delete":
if (!Number.isFinite(action.id)) return "Invalid id";
return null;
default: {
const _never: never = action;
return _never;
}
}
}
Это типизация, которая отражает бизнес-правила. Когда вы меняете структуру данных операции, компилятор подсказывает, где пересмотреть логику.
union vs “опциональные поля”: частая ловушка проектирования
Рассмотрим альтернативу: вместо дискриминатора вы делаете поля опциональными.
type BadResult =
| { ok: true; data: string; error?: never }
| { ok: false; error: string; data?: never };
Это всё ещё union, но мы усложнили модель.
Намного хуже выглядит такой вариант:
type BadApiResponse = {
ok: boolean;
data?: string;
error?: string;
};
Снаружи всё кажется удобным: “поле есть или нет”. Но дальше начинаются проблемы:
- В коде появляются проверки
if (res.data), которые не гарантируют связь сok. - Легко создать рассогласование данных:
ok: trueи одновременноerror: "...". - Типы не могут запретить некорректные комбинации.
С дискриминированным union вы предотвращаете невозможные состояния на уровне типов:
type ApiResponse =
| { ok: true; data: string }
| { ok: false; error: string };
И компилятор удерживает вас от логических “развалов” при чтении.
Как писать narrowing без as: безопасные приемы для компилятора
Вариант 1: switch по дискриминатору (наиболее надёжный)
type Shape =
| { kind: "circle"; r: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape) {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "rect":
return s.w * s.h;
default: {
const _never: never = s;
return _never;
}
}
}
Вариант 2: if по литеральному полю
function isCircle(s: Shape): s is { kind: "circle"; r: number } {
return s.kind === "circle";
}
Здесь важен тип-предикат s is ...: он обучает TypeScript как сужать тип.
Вариант 3: пользовательские type guard-и
Type guard — это функция, которая возвращает true и дополнительно сообщает компилятору, какой тип после проверки.
type Input =
| { type: "number"; value: unknown }
| { type: "string"; value: unknown };
function isNumberInput(i: Input): i is { type: "number"; value: number } {
return i.type === "number" && typeof i.value === "number";
}
function parse(i: Input) {
if (isNumberInput(i)) {
return i.value * 2; // i.value: number
}
// Здесь i автоматически сужается к варианту { type: "string"; value: unknown }
// и мы должны проверить/преобразовать
if (i.type === "string") {
return String(i.value).toUpperCase();
}
}
Если вы часто делаете проверку и затем работаете с конкретным подтипом, type guard-и — лучший способ избежать кастов.
Работа с union при дженериках и типовых ошибках
На практике многие проблемы возникают не в простых switch, а когда union “прячется” за дженериками, generics, mapped types.
Пример: вы хотите получить данные из результата:
type ApiResult<T> =
| { kind: "success"; data: T }
| { kind: "failure"; reason: string };
function unwrap<T>(r: ApiResult<T>) {
// ❌ нельзя просто r.data
return r.data;
}
Правильный вариант — narrowing:
function unwrap<T>(r: ApiResult<T>): T {
if (r.kind === "success") return r.data;
throw new Error(r.reason);
}
Если вы хотите, чтобы функция не бросала ошибку и возвращала T | null:
function toNullable<T>(r: ApiResult<T>): T | null {
return r.kind === "success" ? r.data : null;
}
Это демонстрация принципа: union в типах вынуждает вас явно выбрать модель поведения на failure-ветке.
“Дискриминатор” должен быть стабильным: типовые ошибки в discriminated union
Чтобы narrowing работал предсказуемо, дискриминатор должен быть:
- Литеральным (
"click" | "scroll") - Присутствующим в каждом варианте
- Стабильным (единое поле
kind/type, а не разные имена) - Правильно типизированным на входе (особенно при чтении из
JSON)
Ошибка: дискриминатор становится string
Если вы создаёте объект так, что kind превращается в широкое string, вы теряете narrowing:
const e = { kind: "click", x: 1, y: 2 };
// тип e.kind может стать string вместо "click" (зависит от контекста)
type Event = { kind: "click"; x: number; y: number } | { kind: "scroll"; delta: number };
function handleEvent(ev: Event) {
// ... switch по ev.kind
}
Чтобы сохранить литералы, применяйте as const или правильно задавайте тип при объявлении:
const e = { kind: "click", x: 1, y: 2 } as const;
// e.kind: "click"
Или:
const e: { kind: "click"; x: number; y: number } = { kind: "click", x: 1, y: 2 };
Ошибка: данные из сети без проверки
TypeScript не знает, что пришёл валидный дискриминатор. Например:
async function fetchEvent(): Promise<Event> {
const res = await fetch("/api/event").then(r => r.json());
return res; // ❌ TypeScript считает, что res уже Event
}
Это не narrowing — это доверие. Для реальной безопасности нужны runtime-проверки (например, с валидацией схемы) и затем уже построение discriminated union.
Даже без фреймворков можно сделать type guard:
function isClickEvent(x: any): x is { kind: "click"; x: number; y: number } {
return x && x.kind === "click" && typeof x.x === "number" && typeof x.y === "number";
}
function isScrollEvent(x: any): x is { kind: "scroll"; delta: number } {
return x && x.kind === "scroll" && typeof x.delta === "number";
}
async function fetchEventSafe(): Promise<Event> {
const res = await fetch("/api/event").then(r => r.json());
if (isClickEvent(res)) return res;
if (isScrollEvent(res)) return res;
throw new Error("Unexpected event shape");
}
Это возвращает нас к главной идее: narrowing — не магия компилятора, а способ связать типы и реальные проверки.
Где narrowing особенно полезен: когда вы строите “маленькие автоматы” состояния
В реальных системах вы постоянно сталкиваетесь с бизнес-процессами: “создано → оплачено → выполнено”, “авторизован → обновил профиль → вышел”, “загружено → отрисовано → обрабатывается”.
Union state и дискриминируемые объединения — естественный способ представить это.
Пример: платеж.
type Payment =
| { kind: "created"; id: string }
| { kind: "authorized"; id: string; authCode: string }
| { kind: "captured"; id: string; receipt: string }
| { kind: "failed"; id: string; reason: string };
function canCapture(p: Payment): p is Extract<Payment, { kind: "authorized" }> {
return p.kind === "authorized";
}
function capture(p: Payment): Payment {
if (!canCapture(p)) {
// Здесь TypeScript понимает, что p не authorized.
// Можно решить, что делать: бросить ошибку, вернуть failed и т.п.
return { kind: "failed", id: p.id, reason: `Cannot capture from state ${p.kind}` };
}
// p теперь точно authorized
return { kind: "captured", id: p.id, receipt: `rcpt-${p.authCode}` };
}
Чем сложнее бизнес-логика, тем важнее способность компилятора держать в голове инварианты между состояниями.
“Narrowing вместо кастов” как стиль разработки
Если сформулировать практическое правило:
- используйте
switch/ifпо дискриминатору или по проверкам структуры; - если нужно — оформляйте проверки как type guard;
- избегайте
as, особенно “перекладываний” union на конкретный тип без доказательств.
Касты иногда оправданы (например, в крайних местах с ограниченным знанием), но как системный стиль они быстро превращают типы в декоратив
Комментарии
Пока нет комментариев