$ sudo teach IT
МОДУЛЬ 7 · УРОК 1

Объединённые типы (Union Types)

Как описывать переменные, которые могут иметь несколько различных типов

Цели урока

  • Понять, что такое union type и зачем он нужен
  • Освоить синтаксис объединения типов через символ |
  • Научиться работать с union типами примитивов и объектов
  • Понять механизм сужения типов (type narrowing)
  • Изучить дискриминированные объединения (discriminated unions)
  • Усвоить использование typeof и других type guards
  • Познакомиться с типом never и исчерпывающей проверкой

Введение: почему нам нужны объединённые типы?

Давайте начнём с реальной ситуации. Представьте, что вы разрабатываете функцию для обработки пользовательского ввода. Пользователь может ввести либо число, либо строку. Как вы опишете тип такого аргумента в TypeScript?

Если вы напишёте просто string, то функция не сможет работать с числами. Если напишете number — не сможет работать со строками. А если использовать any — мы потеряем всю типобезопасность.

Именно для таких ситуаций в TypeScript существуют объединённые типы (union types). Они позволяют описать, что переменная может иметь один из нескольких типов.

Аналогия из жизни:

Представьте, что вы описываете требования к кандидату на вакансию: "Нужен сотрудник с образованием или высшее техническое, или высшее экономическое, или среднее специальное". Вы не говорите "нужен сотрудник со всеми образованиями одновременно" — вы говорите, что подойдёт одно из перечисленных. Union types работают точно так же: переменная может иметь один из указанных типов.

Синтаксис union типов

Создание union типа в TypeScript невероятно просто. Вы перечисляете возможные типы через символ вертикальной черты |:

// Объединение двух примитивных типов
let mixed: string | number;

// Присваивание строкового значения — ОК
mixed = "Привет, мир!";

// Присваивание числового значения — тоже ОК
mixed = 42;

// Присваивание булева значения — ОШИБКА!
mixed = true; // Ошибка: Type 'boolean' is not assignable to type 'string | number'

Как видите, синтаксис очень простой: имя типа + символ | + другой тип. Вы можете объединять столько типов, сколько нужно:

// Объединение трёх типов
let multiUnion: string | number | boolean;

multiUnion = "текст"; // ОК
multiUnion = 100; // ОК
multiUnion = false; // ОК
multiUnion = []; // Ошибка!
Важно понимать:

Union type означает "один из типов", а не "все типы одновременно". Переменная типа string | number может быть строкой или числом, но не может быть строкой и числом одновременно.

Union с примитивными типами

Наиболее распространённый случай использования union types — объединение примитивных типов. Давайте рассмотрим несколько практических примеров:

// Функция, принимающая ID в разных форматах
function processId(id: string | number): void {
  console.log(`Обработка ID: ${id}`);
}

processId("abc-123"); // Обработка ID: abc-123
processId(42); // Обработка ID: 42

В JavaScriptмного API могут возвращать разные типы данных. Например, JSON.parse() может вернуть что угодно, а Array.prototype.find() возвращает элемент или undefined.

// Поиск элемента в массиве — возвращает T | undefined
const numbers: number[] = [1, 2, 3, 4, 5];
const found = numbers.find(n => n > 3); // тип: number | undefined

// Обработка результата поиска
if (found !== undefined) {
  console.log(`Найдено: ${found}`); // found теперь типа number
}

Обратите внимание на очень важную деталь: Array.prototype.find() в TypeScript возвращает тип T | undefined. Это типичный пример использования union types — функция может успешно найти элемент и вернуть его, а может не найти и вернуть undefined.

Практический пример:

Попробуйте сами: создайте переменную status: "active" | "inactive" | "pending". Это union из литеральных строковых типов — очень распространённый паттерн для описания состояний.

Union с литеральными типами

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

// Union литеральных строковых типов
type Direction = "up" | "down" | "left" | "right";

let playerDirection: Direction = "up"; // ОК
playerDirection = "left"; // ОК
playerDirection = "forward"; // Ошибка!

// Union литеральных числовых типов
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

const roll: DiceRoll = 3; // ОК
const roll2: DiceRoll = 7; // Ошибка!

// Union булевых литералов
type Toggle = true | false; // эквивалентно boolean

Литеральные типы в union создают очень строгие ограничения. TypeScript не позволит присвоить значение, которое не входит в список допустимых. Это помогает избежать опечаток и логических ошибок на этапе компиляции.

Совет:

Используйте union литеральных типов вместо магических строк в коде. Вместо if (status === "active") лучше создать тип type Status = "active" | "inactive" | "pending" и использовать его. Это сделает код самодокументируемым и безопасным.

Union с объектными типами

Union types работают не только с примитивами — вы можете объединять интерфейсы, типы объектов и классы. Это открывает возможности для гибкой работы с данными разных форматов:

// Объединение двух интерфейсов
interface Cat {
  type: "cat";
  name: string;
  indoor: boolean;
}

interface Dog {
  type: "dog";
  name: string;
  breed: string;
}

type Pet = Cat | Dog;

function getPetInfo(pet: Pet): string {
  return `Имя: ${pet.name}`;
}

// Обе переменные подходят для функции
const myCat: Cat = { type: "cat", name: "Мурка", indoor: true };
const myDog: Dog = { type: "dog", name: "Шарик", breed: "дворняга" };

getPetInfo(myCat); // Имя: Мурка
getPetInfo(myDog); // Имя: Шарик

В этом примере мы создали union типа Pet, который может быть либо Cat, либо Dog. Функция getPetInfo принимает аргумент типа Pet и может работать с любым из этих типов.

Но что произойдёт, если мы попробуем обратиться к свойству indoor, которое есть только у Cat?

function isIndoor(pet: Pet): boolean {
  return pet.indoor; // Ошибка: Property 'indoor' does not exist on type 'Pet'
  // Property 'indoor' does not exist on type 'Dog'
}

TypeScript не позволяет обращаться к свойству, которое существует не у всех типов в union. Это правильное поведение — ведь если переменная окажется типа Dog, то свойства indoor у неё не будет. Чтобы получить доступ к специфическим свойствам, нужно сначала "узкить" тип — об этом мы поговорим дальше.

Что можно использовать:
  • interface + interface
  • type + type
  • interface + type
  • Класс + interface
  • Примитив + объект
  • Любые комбинации

Сужение типов (Type Narrowing)

Когда переменная имеет union тип, TypeScript не позволяет обращаться к свойствам или методам, которые есть не у всех типов в объединении. Это логично — если переменная может быть строкой, у неё нет метода toFixed(), который есть только у чисел.

Но как же тогда работать с такими переменными? Ответ — сужение типов (type narrowing). Это механизм, при котором TypeScript "сужает" тип переменной на основе проверок во время выполнения.

// Простой пример сужения типов
function processValue(value: string | number): void {
  if (typeof value === "string") {
    // Здесь TypeScript ЗНАЕТ, что value — это string
    console.log(value.toUpperCase()); // ОК!
  } else {
    // Здесь TypeScript ЗНАЕТ, что value — это number
    console.log(value.toFixed(2)); // ОК!
  }
}

Заметьте, что после проверки typeof value === "string" TypeScript автоматически понимает, что в ветке if переменная value имеет тип string, а в ветке else — тип number.

Оператор typeof

Оператор typeof — это самый простой способ сужения типов для примитивов. Он возвращает строку, описывающую тип значения:

// typeof для различных типов
function describe(input: string | number | boolean): string {
  switch (typeof input) {
    case "string":
      return `Строка длиной ${input.length} символов`;
    case "number":
      return `Число: ${input.toFixed(2)}`;
    case "boolean":
      return `Булево: ${input ? "истина" : "ложь"}`;
  }
}
Важное замечание:

Оператор typeof НЕ работает для сужения объектных типов! typeof null возвращает "object", и typeof [] тоже возвращает "object". Для объектов используйте другие методы: проверку свойств, instanceof, или дискриминированные объединения.

Дискриминированные объединения (Discriminated Unions)

Дискриминированные объединения — это один из самых мощных паттернов в TypeScript. Они позволяют создавать union типы объектов, которые различаются по общему полю-дискриминанту (discriminant).

Идея проста: все типы в объединении имеют одно общее поле с литеральным типом. Значение этого поля "дискриминирует" (различает) типы друг от друга.

// Определяем фигуры
interface Circle {
  kind: "circle";
  radius: number;
}

interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

// Union тип с дискриминантом "kind"
type Shape = Circle | Rectangle | Triangle;

// Функция, использующая дискриминант
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // shape автоматически сужен до Circle
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // shape автоматически сужен до Rectangle
      return shape.width * shape.height;
    case "triangle":
      // shape автоматически сужен до Triangle
      return (shape.base * shape.height) / 2;
  }
}

Магия дискриминированных объединений в том, что TypeScript автоматически сужает тип в каждой ветке switch или if. После проверки shape.kind === "circle" TypeScript точно знает, что shape — это объект типа Circle, и можно безопасно обращаться к shape.radius.

Почему дискриминированные объединения так популярны?
  • TypeScript понимает их структуру и помогает с сужением типов
  • Код становится самодокументируемым — сразу видно, какие варианты возможны
  • Можно добавить проверку исчерпывающей обработки (exhaustive checking)
  • Идеально подходят для состояний (loading, success, error)

Тип never и исчерпывающая проверка

Тип never в TypeScript представляет значение, которое никогда не возникает. Это может звучать странно, но на самом деле это очень полезный тип, особенно при работе с дискриминированными объединениями.

Когда вы обрабатываете все варианты в switch-операторе, TypeScript позволяет использовать never для проверки, что вы действительно обработали все случаи:

// Исчерпывающая проверка с never
function getShapeName(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return "Круг";
    case "rectangle":
      return "Прямоугольник";
    case "triangle":
      return "Треугольник";
    default:
      // Если все варианты обработаны, shape будет типа never
      const exhaustiveCheck: never = shape;
      return exhaustiveCheck;
  }
}

Если вы добавите новый тип в union Shape, но забудете обработать его в switch, TypeScript выдаст ошибку компиляции! Это гарантирует, что вы обработали все возможные варианты.

// Добавляем новый тип
interface Pentagon {
  kind: "pentagon";
  side: number;
}

type Shape = Circle | Rectangle | Triangle | Pentagon;

// Теперь getShapeName выдаст ОШИБКУ!
// Type 'Pentagon' is not assignable to type 'never'
// Это заставит вас добавить обработку для Pentagon
На практике:

Исчерпывающая проверка — это не просто теоретическая концепция. Она реально защищает вас от ошибок. Если вы добавите новый вариант в union, но забудете обработать его в switch, код не скомпилируется. Это гораздо надёжнее, чем просто логировать ошибку во время выполнения.

Type Guard'ы при работе с union типами

Помимо typeof, TypeScript поддерживает другие механизмы сужения типов — type guard'ы. Давайте рассмотрим наиболее популярные:

Оператор instanceof

Оператор instanceof проверяет, является ли объект экземпляром определённого класса:

// Классы с общим родителем
class HttpError {
  constructor(public statusCode: number) {}
}

class NetworkError {
  constructor(public message: string) {}
}

function handleError(error: HttpError | NetworkError): void {
  if (error instanceof HttpError) {
    // TypeScript знает, что error — это HttpError
    console.log(`HTTP ошибка: ${error.statusCode}`);
  } else {
    // TypeScript знает, что error — это NetworkError
    console.log(`Сетевая ошибка: ${error.message}`);
  }
}

Проверка наличия свойства

Вы можете проверить, существует ли определённое свойство у объекта, чтобы сузить тип:

// Интерфейсы с разными свойствами
interface Fish {
  swim: () => void;
  gills: number;
}

interface Bird {
  fly: () => void;
  wingspan: number;
}

function move(animal: Fish | Bird): void {
  // Проверяем наличие метода swim
  if ("swim" in animal) {
    // TypeScript знает, что animal — это Fish
    animal.swim();
  } else {
    // TypeScript знает, что animal — это Bird
    animal.fly();
  }
}

Пользовательские type guard'ы

Иногда встроенных проверок недостаточно. Вы можете создать собственные type guard'ы — функции, которые возвращают value is Type:

// Пользовательский type guard
function isString(value: unknown): value is string {
  return typeof value === "string";
}

// Пользовательский type guard для объектов
interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "id" in obj &&
    "name" in obj &&
    "email" in obj
  );
}

// Использование
const data: unknown = fetchUser();

if (isUser(data)) {
  // TypeScript знает, что data — это User
  console.log(data.name);
}
Обратите внимание:

Синтаксис value is Type в return type — это именно type guard. TypeScript доверяет этой функции и будет сужать тип при вызове.

Практические паттерны с union типами

Давайте рассмотрим несколько распространённых паттернов, которые вы будете встречать в реальных проектах:

Паттерн 1: Состояния загрузки (Loading State)

Один из самых распространённых паттернов — описание состояния асинхронной операции:

// Описываем состояния загрузки
type LoadingState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: string };

// Функция отрисовки состояния
function renderState(state: LoadingState): string {
  switch (state.status) {
    case "idle":
      return "Готов к загрузке";
    case "loading":
      return "Загрузка...";
    case "success":
      // state автоматически сужен — у него есть поле data
      return `Загружено ${state.data.length} элементов`;
    case "error":
      // state автоматически сужен — у него есть поле error
      return `Ошибка: ${state.error}`;
  }
}

Паттерн 2: API Response

При работе с API часто используется union тип для описания разных вариантов ответа:

// Варианты ответа API
interface ApiResponse<T> {
  status: number;
  ok: boolean;
}

interface SuccessResponse<T> extends ApiResponse<T> {
  ok: true;
  data: T;
}

interface ErrorResponse extends ApiResponse<never> {
  ok: false;
  error: string;
}

// Union тип для ответа
type Result<T> = SuccessResponse<T> | ErrorResponse;

// Обработка ответа
function handleResult<T>(result: Result<T>): void {
  if (result.ok) {
    // result — SuccessResponse, у него есть data
    console.log("Данные:", result.data);
  } else {
    // result — ErrorResponse, у него есть error
    console.log("Ошибка:", result.error);
  }
}

Паттерн 3: Optional/Nullable типы

Union с null и undefined используется повсеместно для описания значений, которые могут отсутствовать:

// Функция поиска пользователя
function findUser(id: number): User | null {
  // ... логика поиска
  return user || null;
}

// Безопасная работа с результатом
const user = findUser(1);

// Проверка на null
if (user !== null) {
  // TypeScript знает, что user — это User
  console.log(user.name);
} else {
  // user — это null
  console.log("Пользователь не найден");
}

// Или с optional chaining
const userName = user?.name ?? "Неизвестно";

Частые ошибки при работе с union типами

Давайте разберём ошибки, которые чаще всего делают разработчики при работе с union типами:

Ошибка 1: Попытка вызвать метод, который есть не у всех типов
function process(val: string | number) {
  val.toFixed(2); // Ошибка! toFixed есть только у number
}

// Решение: сузить тип
function process(val: string | number) {
  if (typeof val === "number") {
    val.toFixed(2); // ОК!
  }
}
Ошибка 2: Забыть проверить все варианты
type Status = "active" | "inactive" | "pending";

function getStatus(s: Status): number {
  if (s === "active") return 1;
  // Забыли inactive и pending!
  // Может вернуть undefined, хотя обещали number
}

// Решение: использовать exhaustive check
function getStatus(s: Status): number {
  switch (s) {
    case "active": return 1;
    case "inactive": return 0;
    case "pending": return -1;
  }
}
Ошибка 3: Использование any вместо union типа
// Плохо: потеря типобезопасности
function process(val: any): any {
  // ...
}

// Хорошо: чёткое описание типов
function process(val: string | number): string {
  // ...
}
Ошибка 4: Неправильное использование typeof для объектов
// typeof НЕ различает разные интерфейсы
interface Cat { meow: () => void; }
interface Dog { bark: () => void; }

// typeof(obj) === "object" для обоих!
// Нужно использовать: "meow" in obj или дискриминант

Продвинутые техники

Сужение типов в замыканиях

TypeScript умеет сужать типы внутри замыканий, но не всегда это работает предсказуемо. Вот как это работает:

// Сужение работает в замыкании
function processItems(items: (string | number)[]): void {
  items.forEach(item => {
    // TypeScript помнит сужение
    if (typeof item === "string") {
      item.toUpperCase(); // ОК!
    }
  });
}

Union типы сgenerics (generics)

Union types отлично работают сgenerics, создавая мощные гибкие типы:

// Union тип вgenerics
function createElement<T extends string | number>(value: T): T {
  return value;
}

// TypeScript выведет тип
const a = createElement("hello"); // тип: "hello"
const b = createElement(42); // тип: 42

// Явное указание типа
const c = createElement<string | number>("test"); // тип: string | number

Утилиты типов с union

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

// Distributive Conditional Types
// distributes over union automatically
type ToArray<T> = T extends any ? T[] : never;

// Применяется к каждому элементу union
type Result = ToArray<string | number>;
// Результат: string[] | number[]

// Exclude — исключает типы из union
type OnlyStrings = Exclude<string | number | boolean, number | boolean>;
// Результат: string

// Extract — извлекает типы из union
type JustNumbers = Extract<string | number | boolean, number>;
// Результат: number

// NonNullable — убирает null и undefined
type Required = NonNullable<string | null | undefined>;
// Результат: string

Советы и рекомендации

Совет 1: Используйте дискриминированные объединения

Вместо обычных union объектов всегда добавляйте поле-дискриминант (например, kind, type, status). Это делает код более читаемым и позволяет TypeScript лучше помогать вам с сужением типов.

Совет 2: Всегда проверяйте исчерпывающую обработку

При работе с дискриминированными объединениями добавляйте default case с присваиванием типа never. Это гарантирует, что вы обработали все варианты.

Совет 3: Именуйте union типы

Если вы используете один и тот же union много раз, создайте для него отдельный тип с понятным именем. Это сделает код самодокументируемым: type UserID = string | number гораздо понятнее, чем повторяющийся string | number.

Совет 4: Избегайте any — используйте unknown

Если вы не знаете тип значения, используйте unknown вместо any. unknown безопаснее — он требует проверки типа перед использованием.

Совет 5: Используйте optional chaining с union типами

При работе с T | null или T | undefined используйте optional chaining (?.operator) и nullish coalescing (??) для безопасного доступа к свойствам.

Совет 6: Создавайте type guard'ы для сложных проверок

Если проверка типа становится сложной, вынесите её в отдельную функцию-type guard. Это делает код чище и позволяет переиспользовать логику проверки.

Полный пример: калькулятор с обработкой ошибок

Давайте объём все изученные концепции в одном комплексном примере — мини-калькуляторе с обработкой разных типов входных данных и ошибок:

// Типы для результата вычислений
interface CalcSuccess {
  kind: "success";
  value: number;
  operation: string;
}

interface CalcError {
  kind: "error";
  message: string;
}

type CalcResult = CalcSuccess | CalcError;

// Операции
type Operation = "add" | "subtract" | "multiply" | "divide";

// Входные данные
type CalcInput = string | number | boolean;

// Преобразование входных данных
function parseInput(input: CalcInput): number | null {
  if (typeof input === "number") {
    return input;
  }
  if (typeof input === "string") {
    const num = Number(input);
    return isNaN(num) ? null : num;
  }
  // boolean
  return input ? 1 : 0;
}

// Калькулятор
function calculate(
  a: CalcInput,
  op: Operation,
  b: CalcInput
): CalcResult {
  const numA = parseInput(a);
  const numB = parseInput(b);

  if (numA === null || numB === null) {
    return { kind: "error", message: "Некорректный ввод" };
  }

  switch (op) {
    case "add":
      return { kind: "success", value: numA + numB, operation: "сложение" };
    case "subtract":
      return { kind: "success", value: numA - numB, operation: "вычитание" };
    case "multiply":
      return { kind: "success", value: numA * numB, operation: "умножение" };
    case "divide":
      if (numB === 0) {
        return { kind: "error", message: "Деление на ноль" };
      }
      return { kind: "success", value: numA / numB, operation: "деление" };
  }
}

// Использование
const result = calculate("10", "divide", 3);

if (result.kind === "success") {
  console.log(`Результат ${result.operation}: ${result.value}`);
} else {
  console.log(`Ошибка: ${result.message}`);
}

Резюме

В этом уроке мы подробно изучили объединённые типы в TypeScript. Давайте подведём итоги:

  • Union types — это типы, создаваемые через символ |, описывающие переменную, которая может иметь один из нескольких типов
  • Сужение типов (narrowing) — TypeScript автоматически "сужает" тип на основе проверок во время выполнения (typeof, instanceof, switch, if)
  • Дискриминированные объединения — паттерн, при котором все типы в union имеют общее поле с литеральным типом для различения
  • Type guard'ы — механизмы для безопасного сужения типов (typeof, instanceof, in, пользовательские guard'ы)
  • Тип never — используется для исчерпывающей проверки всех вариантов в union
  • Утилиты типов — Exclude, Extract, NonNullable помогают манипулировать union типами
Ключевые моменты для запоминания:
  • Union type — это "ИЛИ" (one of), а не "И" (all of)
  • Всегда сужайте тип перед использованием специфических методов/свойств
  • Используйте дискриминированные объединения для объектов
  • Добавляйте exhaustive check для гарантии обработки всех вариантов
  • Создавайте именованные типы для часто повторяющихся union'ов

Что дальше?

Теперь, когда вы понимаете union types, обратите внимание на следующие темы: Intersection Types (пересечение типов через &), Conditional Types (условные типы), и Mapped Types (сопоставление типов). Union types — это основа для понимания этих более продвинутых концепций.

Тест по Union Types

10 вопросов

Discriminated union для фигур

Premium

Сужение union с помощью type guard

Premium