Урок 17: Объединения (Union Types) в TypeScript

Сегодня нас ждет очень важная и одна из краеугольных тем в системе типов этого языка, это объединения (Union Types). Если до этого мы работали с переменными, которые могли быть только одного, строго предопределенного типа, то сегодня мы научимся создавать гибкие конструкции, которые могут принимать значения из двух или более типов. Это как если бы у вас был универсальный ключ, который подходил бы к дверям, замкам и сейфам одновременно. Давайте разбираться.

Что такое объединения (Union Types)?

В реальных приложениях мы часто сталкиваемся с ситуациями, когда какая-то сущность не может быть описана одним единственным типом. Представьте себе функцию, которая обрабатывает идентификатор. Этот идентификатор в разных сценариях может быть как числом (например, 123), так и строкой (например, "user-abc123"). Или переменная, которая может содержать либо код статуса (число), либо текстовое сообщение об ошибке (строка), либо null, если ошибки нет.

В чистом JavaScript мы бы просто написали let id = ... и работали бы с ним, надеясь, что во всех частях программы нам придут ожидаемые типы данных. Это источник постоянных ошибок и проверок. TypeScript, будучи типизированной надстройкой, не позволяет так легко с собой обращаться. Он требует точности. Но он также дает нам инструменты для описания такой «множественной» природы данных. Этот инструмент и называется Объединение (Union Type).

Объединение, это тип который представляет собой совокупность других типов. Значение, принадлежащее к типу-объединению, может быть значением любого из этих типов. Мы создаем объединение с помощью оператора вертикальной черты | (в народе «pipe»).

Синтаксис до безобразия прост:
Тип1 | Тип2 | Тип3 ...

Вернемся к нашему примеру с идентификатором:

typescript
let userId: number | string;
// Теперь переменной userId можно присвоить и число и строку
userId = 10;    // OK
userId = "abc123"; // OK
userId = true;  // Ошибка! Type 'boolean' is not assignable to type 'string | number'.

Вот и все! Мы только что создали наш первый union-тип. Теперь TypeScript знает, что userId это либо строка, либо число и будет проверять код accordingly, а также предоставлять подсказки для общих свойств этих типов.

Зачем это нужно? Безопасность и ясность. Мы явно документируем свое намерение: «Эта переменная может быть вот таких двух типов и больше никаких». Компилятор начинает следить за этим правилом, не давая нам случайно присвоить userId значение булевого типа. А в местах, где мы работаем с этой переменной, TypeScript будет заставлять нас учитывать оба возможных сценария, что приводит к более надежному и предсказуемому коду. Это классический принцип «заставить ошибки проявляться как можно раньше», еще на этапе написания кода в редакторе.

Базовые примеры объединений: number | string, string | boolean и т.д.

Давайте для закрепления понятия рассмотрим еще несколько простых примеров. Основа, это примитивные типы.

Пример 1: Разные примитивы

typescript
let value: number | string | boolean;

value = 42; // OK
value = "Hello, Union!"; // OK
value = false; // OK
value = []; // Ошибка! Массив не входит в объединение.

Пример 2: Работа с статусом
Часто встречающаяся ситуация, обработка статуса операции.

typescript
let status: "success" | "error" | "loading";
// Это тоже объединение, но литеральных типов (о них мы говорили в уроке по литеральным типам).

status = "success"; // OK
status = "error";   // OK
status = "loading"; // OK
status = "pending"; // Ошибка! Такого варианта в объединении нет.

Этот пример особенно мощный, так как он создает по-настоящему типобезопасный перечисляемый тип (enum). Вы не сможете ошибиться и присвоить статусу любое другое строковое значение.

Пример 3: Массив разных типов
Что, если мы хотим создать массив, который содержит и числа и строки?

typescript
let mixedArray: (number | string)[] = []; // Круглые скобки важны для приоритета!
mixedArray.push(1);
mixedArray.push("two");
mixedArray.push(true); // Ошибка! Boolean не разрешен.

Обратите внимание на скобки. Запись number | string[] означала бы «либо число, либо массив строк». А запись (number | string)[] означает «массив, каждый элемент которого может быть либо числом, либо строкой». Разница принципиальная.

Пример 4: Объединение с null и undefined
Это очень распространенный паттерн, особенно когда данные приходят извне (например, из API).

typescript
let responseData: string | null;
// Изначально данных нет, поэтому null
responseData = null; // OK
// Потом пришел ответ от сервера
responseData = "Данные пользователя..."; // OK

Так мы явно показываем, что переменная может отсутствовать. Это альтернатива необязательным свойствам (с символом ?) для переменных.

Потренируйтесь сами. Объявите переменные со следующими объединениями:

  1. number | null

  2. boolean | undefined

  3. (boolean | number)[]

Посмотрите, как ведет себя автодополнение в вашей IDE (VSCode, WebStorm) для этих переменных. Вы увидите, что оно предлагает только методы и свойства, общие для всех типов в объединении. А это подводит нас к следующему, самому важному вопросу.

Сужение типа (Type Narrowing): как работать с объединением внутри кода

Итак, мы объявили переменную let id: number | string;. И теперь мы хотим выполнить над ней какие-то операции. Например, вызвать метод toUpperCase(), если это строка или метод toFixed(), если это число.

Если мы попробуем сделать это сразу, TypeScript выдаст ошибку:

typescript
function printId(id: number | string) {
  console.log(id.toUpperCase());
  // Ошибка: Property 'toUpperCase' does not exist on type 'string | number'.
  // Property 'toUpperCase' does not exist on type 'number'.
}

Он прав! У числа нет метода toUpperCase(). Компилятор видит, что тип переменной id это широкое объединение number | string. Он не может быть уверен, какой именно тип там находится в момент вызова метода, поэтому он разрешает использовать только те операции, которые являются общими для ВСЕХ типов в объединении. Для number и string общими, например, являются методы toString()valueOf().

Но как же нам тогда работать с такими переменными? Нам нужно каким-то образом сузить (narrow) тип переменной до конкретного составляющего. Этот процесс называется Type Narrowing (Сужение Типа). TypeScript предоставляет несколько механизмов для этого и самый главный из них, это проверка во время выполнения с помощью JavaScript.

1. Сужение типа с помощью typeof

Самый простой и очевидный способ использовать оператор typeof.

typescript
function printId(id: number | string) {
  if (typeof id === "string") {
    // В этой ветке TypeScript *знает*, что id это строка!
    console.log(id.toUpperCase()); // OK
  } else {
    // А в этой ветке TypeScript понимает, что id это не строка.
    // Поскольку объединение состоит только из string и number, значит здесь number.
    console.log(id.toFixed(2)); // OK
  }
}

Компилятор анализирует поток управления и понимает, что внутри блока if тип переменной id сужается до string. Это и есть суть TypeScript!

Давайте рассмотрим еще пример, с тремя типами:

typescript
function handleValue(value: number | string | boolean) {
  if (typeof value === "string") {
    console.log("Строка: " + value.toLowerCase());
  } else if (typeof value === "number") {
    console.log("Число: " + value.toPrecision(2));
  } else {
    // Остался только boolean
    console.log("Булево значение: " + value);
  }
}

Важное замечание: typeof отлично работает с примитивами (stringnumberbooleansymbolundefined), но для массивов, объектов и null он возвращает "object", что не очень помогает их различать. Для них мы используем другие техники.

2. Сужение типа с помощью проверки на равенство (Equality Narrowing)

Иногда мы можем сузить тип, просто проверив значение на строгое равенство (=== или !==).

typescript
function printStatus(status: "success" | "error" | "loading") {
  if (status === "loading") {
    console.log("Запрос еще выполняется...");
    // Здесь status сужен до литерального типа "loading"
    return;
  }

  if (status === "success") {
    console.log("Данные успешно загружены!");
    // Здесь status сужен до "success"
  } else {
    // Поскольку остались только "success" и "error", а "success" мы уже проверили,
    // здесь TypeScript автоматически сужает тип до "error"
    console.error("Произошла ошибка!");
  }
}

3. Сужение типа для объектов: проверка наличия свойства (Property Check)

Допустим, у нас есть объединение двух объектных типов. Как нам их различить? Мы можем проверить наличие уникального для одного из типов свойства.

typescript
type Dog = {
  name: string;
  breed: string;
  bark: () => void;
};

type Cat = {
  name: string;
  breed: string;
  meow: () => void;
};

function makeSound(animal: Dog | Cat) {
  // У обоих животных есть name и breed, поэтому обратиться к ним можно
  console.log(animal.name);

  // А вот bark есть только у Dog
  if ("bark" in animal) {
    // TypeScript понимает, что если свойство bark существует, то это Dog
    animal.bark(); // OK
  } else {
    // Следовательно, здесь это Cat
    animal.meow(); // OK
  }
}

Эта техника называется «проверка по наличию свойства» (property check) или «защитник типа» (type guard).

Практическое применение: функции, принимающие разные типы

Теперь давайте посмотрим, как объединения раскрывают свою мощь в функциях. Это, пожалуй, их основное применение.

Пример 1: Функция для форматирования значения
Напишем функцию, которая может принимать на вход число или строку, а возвращает отформатированную строку.

typescript
function formatValue(value: number | string): string {
  if (typeof value === "number") {
    // Сужение до number
    return `Число: ${value.toFixed(2)}`;
  }
  // Здесь value автоматически сужено до string
  return `Строка: ${value.trim()}`;
}

console.log(formatValue(42)); // "Число: 42.00"
console.log(formatValue("  Hello!   ")); // "Строка: Hello!"

Пример 2: Функция для парсинга ID (очень распространенный кейс!)
Данные из форм или query-параметров URL часто приходят в виде строк. Но наш внутренний API может ожидать число.

typescript
function parseId(id: string | number): number {
  // Если id уже число, просто возвращаем его
  if (typeof id === "number") {
    return id;
  }
  // Если это строка, пытаемся преобразовать
  const parsed = parseInt(id, 10);
  if (isNaN(parsed)) {
    throw new Error("Неверный формат ID. Ожидается число или числовая строка.");
  }
  return parsed;
}

const id1 = parseId("123"); // Вернет number 123
const id2 = parseId(123);   // Вернет number 123
const id3 = parseId("abc"); // Выбросит ошибку

Продвинутое использование: объединения с пользовательскими типами (Aliases и Interfaces)

Объединения прекрасно работают не только с примитивами, но и с созданными нами типами (type) и интерфейсами (interface). Это позволяет строить сложные и expressive модели данных.

Представьте, что вы описываете ответ от сервера. Успешный ответ и ответ с ошибкой имеют разную структуру.

typescript
// Определяем интерфейсы для разных исходов
interface SuccessResponse {
  status: "success";
  data: {
    userId: number;
    userName: string;
  };
}

interface ErrorResponse {
  status: "error";
  message: string;
  code: number;
}

// Создаем объединение
type ApiResponse = SuccessResponse | ErrorResponse;

// Функция для обработки ответа
function handleResponse(response: ApiResponse) {
  // Сначала сужаем тип по свойству status, общему для всех
  if (response.status === "success") {
    // Теперь TypeScript знает, что response это SuccessResponse
    // и у него есть свойство data
    console.log(`Данные пользователя: ${response.data.userName}`);
  } else {
    // Сужено до ErrorResponse
    console.error(`Ошибка ${response.code}: ${response.message}`);
  }
}

// Используем
const successRes: ApiResponse = {
  status: "success",
  data: { userId: 1, userName: "Max" }
};
handleResponse(successRes); // "Данные пользователя: Max"

const errorRes: ApiResponse = {
  status: "error",
  message: "Пользователь не найден",
  code: 404
};
handleResponse(errorRes); // "Ошибка 404: Пользователь не найден"

Этот паттерн называется «Размеченное объединение» (Discriminated Union) или «Объединение с тегом» (Tagged Union). «Тегом» здесь является общее для всех типов свойство (в нашем случае status), которое имеет литеральные типы. Это очень мощный и безопасный способ работы с разными формами данных.

Практические задачи для закрепления

Давайте решим несколько задач. Попробуйте сделать их самостоятельно, прежде than смотреть решение.

Задача 1: Функция объединения массивов
Напишите функцию combineArrays, которая принимает два аргумента. Оба аргумента могут быть либо массивом чисел, либо массивом строк. Функция должна возвращать новый массив, который является конкатенацией двух входных массивов. Гарантируется, что если оба аргумента массивы, то они одного типа (все числа или все строки).

Пример вызова:
combineArrays([1, 2, 3], [4, 5]) // [1, 2, 3, 4, 5]
combineArrays(['a', 'b'], ['c', 'd']) // ['a', 'b', 'c', 'd']

Задача 2: Определение типа фигуры
Создайте тип Shape, который может быть либо кругом (Circle), либо прямоугольником (Rectangle).

  • Круг имеет свойство kind: 'circle' и radius: number.

  • Прямоугольник имеет свойство kind: 'rectangle'width: number и height: number.

Напишите функцию calculateArea, которая принимает аргумент типа Shape и возвращает площадь фигуры. Для круга площадь = π * r^2. Для прямоугольника = width * height. Используйте Discriminated Union и сужение типа.

Задача 3: Обработчик событий
Представьте, что вы обрабатываете события от кнопки и поля ввода.
Создайте тип ButtonClickEvent с свойством type: 'click' и target: HTMLButtonElement.
Создайте тип InputChangeEvent с свойством type: 'change' и target: HTMLInputElement и value: string.

Создайте объединение AppEvent для этих двух событий.
Напишите функцию handleEvent(event: AppEvent), которая в зависимости от типа события выводит в консоль:

  • Для ‘click’: «Клик на кнопку с id: …» (подставьте id элемента)

  • Для ‘change’: «Ввод в поле: …» (подставьте value)

Решения задач

Решение Задачи 1:

typescript
function combineArrays(arr1: number[] | string[], arr2: number[] | string[]): number[] | string[] {
  // Поскольку оба массива гарантированно одного типа, мы можем просто их объединить.
  return [...arr1, ...arr2];
}

// Более точная и правильная версия с использованием Generics (о них мы поговорим позже):
// function combineArrays<T extends number | string>(arr1: T[], arr2: T[]): T[] {
//   return [...arr1, ...arr2];
// }

// Проверка
const numResult = combineArrays([1, 2, 3], [4, 5]);
const strResult = combineArrays(['a', 'b'], ['c', 'd']);
console.log(numResult); // [1, 2, 3, 4, 5]
console.log(strResult); // ['a', 'b', 'c', 'd']

Решение Задачи 2:

typescript
// 1. Определяем интерфейсы для каждой фигуры с дискриминатором `kind`
interface Circle {
  kind: 'circle';
  radius: number;
}

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

// 2. Создаем объединение
type Shape = Circle | Rectangle;

// 3. Пишем функцию с сужением типа
function calculateArea(shape: Shape): number {
  if (shape.kind === 'circle') {
    // Здесь shape сужен до Circle
    return Math.PI * shape.radius ** 2;
  } else {
    // Здесь shape сужен до Rectangle
    return shape.width * shape.height;
  }
}

// Альтернатива с switch (очень популярна для Discriminated Unions)
function calculateAreaSwitch(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    // default - хорошая практика на случай, если в объединение добавят новый тип
    default:
      // Эта проверка гарантирует, что мы обработали все возможные варианты!
      // Если мы добавим новый тип в Shape, например, Triangle, то TypeScript укажет на ошибку здесь:
      // Type 'Triangle' is not assignable to type 'never'.
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

// Проверка
const myCircle: Circle = { kind: 'circle', radius: 5 };
const myRect: Rectangle = { kind: 'rectangle', width: 4, height: 6 };

console.log(calculateArea(myCircle)); // ~78.54
console.log(calculateArea(myRect)); // 24

Решение Задачи 3:

typescript
// Для работы с DOM-элементами нам нужна соответствующая библиотека типов.
// Обычно это добавляется автоматически в проектах типа React или при подключении `@types/node`.
// Мы опишем элементы очень упрощенно.

type ButtonClickEvent = {
  type: 'click';
  target: { id: string; tagName: 'BUTTON' }; // Упрощенная модель HTMLButtonElement
};

type InputChangeEvent = {
  type: 'change';
  target: { value: string; tagName: 'INPUT' }; // Упрощенная модель HTMLInputElement
  value: string;
};

type AppEvent = ButtonClickEvent | InputChangeEvent;

function handleEvent(event: AppEvent): void {
  switch (event.type) {
    case 'click':
      console.log(`Клик на кнопку с id: ${event.target.id}`);
      break;
    case 'change':
      // Обратите внимание: event.value и event.target.value в данном случае одно и то же.
      // В реальном DOM event.target.value - это строка, а у нашего объекта события есть отдельное свойство value.
      console.log(`Ввод в поле: ${event.value}`);
      break;
    default:
      const _exhaustiveCheck: never = event;
      console.log('Неизвестное событие', _exhaustiveCheck);
  }
}

// Проверка
handleEvent({ type: 'click', target: { id: 'btn-submit', tagName: 'BUTTON' } });
handleEvent({ type: 'change', target: { value: 'Max', tagName: 'INPUT' }, value: 'Max' });

Заключение

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

На следующем уроке мы изучим концепцию-сестру объединений, Пересечения (Intersection Types). Они позволяют комбинировать типы, а не выбирать между ними. Будет тоже очень интересно!

Вернуться к полному курсу «TypeScript для начинающих»

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий