$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогСистема типов как инструмент мышления: union, narrowing и дискриминируемые объединения в TypeScript

Система типов как инструмент мышления: union, narrowing и дискриминируемые объединения в TypeScript

$ sudo teach IT
·20 июля 2026 г.·11 мин·53
Система типов как инструмент мышления: union, narrowing и дискриминируемые объединения в TypeScript

На реальных примерах разберём, как проектировать безопасные модели данных и извлекать пользу из narrowing вместо кастов.

Содержание
Union-тип: почему это больше, чем “разрешить два варианта”Неправильные подходы: касты и почему они ломают “мышление типами”Narrowing: как TypeScript сужает тип до конкретного варианта1) typeof и instanceof2) Проверки полей в объектных unions3) in для определения наличия поляДискриминируемые объединения: практичный паттерн для “правильного” narrowingБазовая модель: событияЗачем never в “исчерпывающих” switch и как его использовать правильноПроектирование моделей данных: как выбирать структуру union1) Результаты запросов: loading | success | error2) Описание команд/операцийunion vs “опциональные поля”: частая ловушка проектированияКак писать narrowing без as: безопасные приемы для компилятораВариант 1: switch по дискриминатору (наиболее надёжный)Вариант 2: if по литеральному полюВариант 3: пользовательские type guard-иРабота с union при дженериках и типовых ошибках“Дискриминатор” должен быть стабильным: типовые ошибки в discriminated unionОшибка: дискриминатор становится stringОшибка: данные из сети без проверкиГде narrowing особенно полезен: когда вы строите “маленькие автоматы” состояния“Narrowing вместо кастов” как стиль разработки

TypeScript часто воспринимают как «более строгий JavaScript». На практике же его система типов — это способ заставить программу думать вместе с вами. Когда модель данных описана корректно, вы меньше полагаетесь на касты, больше — на инварианты, и код начинает защищать вас от целых классов ошибок: неправильных форматов, забытых веток обработки и «невозможных» состояний.

В этой статье разберём, как проектировать безопасные модели данных с помощью:

  • union-типов (A | B)
  • narrowing (сужение типа) вместо приведения (as)
  • дискриминируемых объединений (discriminated union) — самого практичного паттерна для TypeScript

Будем опираться на реальные примеры: от описания событий до безопасной бизнес-логики. Цель — чтобы вы научились не «обходить» типы, а извлекать из них пользу.


Union-тип: почему это больше, чем “разрешить два варианта”

Union-тип в TypeScript означает: значение может быть одного из типов. Например:

code
type UserId = number | string;

function formatUser(id: UserId) {
  return `id=${id}`;
}

Здесь всё просто: функция принимает значение, которое может быть number или string. Но ключевая проблема возникает почти сразу: как работать с id, если вы не знаете конкретный вариант?

TypeScript заставляет вас думать: если вы делаете операцию, допустимую только для number, вы должны сначала сузить тип до number.

code
type UserId = number | string;

function doubleId(id: UserId) {
  // ❌ id может быть string — нельзя умножать без narrowing
  return id * 2;
}

Типизация здесь — не «помеха», а сигнал: вы должны описать правила, когда именно id окажется числом, и обработать обе ветки.


Неправильные подходы: касты и почему они ломают “мышление типами”

Частая ошибка — использовать касты ради удобства. Например:

code
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

Для простых случаев:

code
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

code
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 для определения наличия поля

code
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) с уникальными значениями.

Базовая модель: события

Рассмотрим события из фронтенда/бэкенда:

code
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 описан корректно, но вы забываете обработать ветку. Тогда код становится непредсказуемым, и баг уходит в рантайм.

Шаблон:

code
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

Пусть у нас есть хранилище состояния:

code
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) Описание команд/операций

Обычно безопаснее моделировать операции как данные, а выполнение — как обработчик:

code
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 “опциональные поля”: частая ловушка проектирования

Рассмотрим альтернативу: вместо дискриминатора вы делаете поля опциональными.

code
type BadResult =
  | { ok: true; data: string; error?: never }
  | { ok: false; error: string; data?: never };

Это всё ещё union, но мы усложнили модель.

Намного хуже выглядит такой вариант:

code
type BadApiResponse = {
  ok: boolean;
  data?: string;
  error?: string;
};

Снаружи всё кажется удобным: “поле есть или нет”. Но дальше начинаются проблемы:

  • В коде появляются проверки if (res.data), которые не гарантируют связь с ok.
  • Легко создать рассогласование данных: ok: true и одновременно error: "...".
  • Типы не могут запретить некорректные комбинации.

С дискриминированным union вы предотвращаете невозможные состояния на уровне типов:

code
type ApiResponse =
  | { ok: true; data: string }
  | { ok: false; error: string };

И компилятор удерживает вас от логических “развалов” при чтении.


Как писать narrowing без as: безопасные приемы для компилятора

Вариант 1: switch по дискриминатору (наиболее надёжный)

code
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 по литеральному полю

code
function isCircle(s: Shape): s is { kind: "circle"; r: number } {
  return s.kind === "circle";
}

Здесь важен тип-предикат s is ...: он обучает TypeScript как сужать тип.

Вариант 3: пользовательские type guard-и

Type guard — это функция, которая возвращает true и дополнительно сообщает компилятору, какой тип после проверки.

code
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.

Пример: вы хотите получить данные из результата:

code
type ApiResult<T> =
  | { kind: "success"; data: T }
  | { kind: "failure"; reason: string };

function unwrap<T>(r: ApiResult<T>) {
  // ❌ нельзя просто r.data
  return r.data;
}

Правильный вариант — narrowing:

code
function unwrap<T>(r: ApiResult<T>): T {
  if (r.kind === "success") return r.data;
  throw new Error(r.reason);
}

Если вы хотите, чтобы функция не бросала ошибку и возвращала T | null:

code
function toNullable<T>(r: ApiResult<T>): T | null {
  return r.kind === "success" ? r.data : null;
}

Это демонстрация принципа: union в типах вынуждает вас явно выбрать модель поведения на failure-ветке.


“Дискриминатор” должен быть стабильным: типовые ошибки в discriminated union

Чтобы narrowing работал предсказуемо, дискриминатор должен быть:

  1. Литеральным ("click" | "scroll")
  2. Присутствующим в каждом варианте
  3. Стабильным (единое поле kind/type, а не разные имена)
  4. Правильно типизированным на входе (особенно при чтении из JSON)

Ошибка: дискриминатор становится string

Если вы создаёте объект так, что kind превращается в широкое string, вы теряете narrowing:

code
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 или правильно задавайте тип при объявлении:

code
const e = { kind: "click", x: 1, y: 2 } as const;
// e.kind: "click"

Или:

code
const e: { kind: "click"; x: number; y: number } = { kind: "click", x: 1, y: 2 };

Ошибка: данные из сети без проверки

TypeScript не знает, что пришёл валидный дискриминатор. Например:

code
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:

code
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 и дискриминируемые объединения — естественный способ представить это.

Пример: платеж.

code
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 на конкретный тип без доказательств.

Касты иногда оправданы (например, в крайних местах с ограниченным знанием), но как системный стиль они быстро превращают типы в декоратив

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

TypeScript - для начинающих!

Курс по теме

TypeScript - для начинающих!

С нуля до уверенной работы с типами в JavaScript. Подходит для начинающих и тех, кто уже пишет на JS. Разберитесь с TypeScript раз и навсегда — и перестаньте ловить баги в рантайме.

Бесплатно
Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Приложения для iPhone и Apple Watch на SwiftUI

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Приложения для macOS на SwiftUI

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

Перейти к курсу

Другие статьи

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают
typescript

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают

Сравниваем подходы на реальных примерах кода, разбираем, при каких размерах и архитектурах проекта TypeScript окупается, а когда достаточно JSDoc.

16 июля 2026 г.
730
Generics в TypeScript: пишем универсальный код без потери типобезопасности
generics

Generics в TypeScript: пишем универсальный код без потери типобезопасности

Разбираем обобщённые типы на практике — от простых функций до сложных ограничений с extends и keyof. После статьи вы перестанете избегать дженериков и начнёте использовать их осознанно.

18 июля 2026 г.
700
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1421
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Union-тип: почему это больше, чем “разрешить два варианта”Неправильные подходы: касты и почему они ломают “мышление типами”Narrowing: как TypeScript сужает тип до конкретного варианта1) typeof и instanceof2) Проверки полей в объектных unions3) in для определения наличия поляДискриминируемые объединения: практичный паттерн для “правильного” narrowingБазовая модель: событияЗачем never в “исчерпывающих” switch и как его использовать правильноПроектирование моделей данных: как выбирать структуру union1) Результаты запросов: loading | success | error2) Описание команд/операцийunion vs “опциональные поля”: частая ловушка проектированияКак писать narrowing без as: безопасные приемы для компилятораВариант 1: switch по дискриминатору (наиболее надёжный)Вариант 2: if по литеральному полюВариант 3: пользовательские type guard-иРабота с union при дженериках и типовых ошибках“Дискриминатор” должен быть стабильным: типовые ошибки в discriminated unionОшибка: дискриминатор становится stringОшибка: данные из сети без проверкиГде narrowing особенно полезен: когда вы строите “маленькие автоматы” состояния“Narrowing вместо кастов” как стиль разработки