Объединённые типы (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 — объединение примитивных типов. Давайте рассмотрим несколько практических примеров:
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.
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 с литеральными типами
Помимо обычных примитивных типов, вы можете объединять литеральные типы — конкретные значения. Это особенно полезно для описания ограниченного набора допустимых значений:
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?
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 — это самый простой способ сужения типов для примитивов. Он возвращает строку, описывающую тип значения:
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 для проверки, что вы действительно обработали все случаи:
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:
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 тип для описания разных вариантов ответа:
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 типами:
val.toFixed(2); // Ошибка! toFixed есть только у number
}
// Решение: сузить тип
function process(val: string | number) {
if (typeof val === "number") {
val.toFixed(2); // ОК!
}
}
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;
}
}
function process(val: any): any {
// ...
}
// Хорошо: чёткое описание типов
function process(val: string | number): string {
// ...
}
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, создавая мощные гибкие типы:
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 типами:
// 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
Советы и рекомендации
Вместо обычных union объектов всегда добавляйте поле-дискриминант (например, kind, type, status). Это делает код более читаемым и позволяет TypeScript лучше помогать вам с сужением типов.
При работе с дискриминированными объединениями добавляйте default case с присваиванием типа never. Это гарантирует, что вы обработали все варианты.
Если вы используете один и тот же union много раз, создайте для него отдельный тип с понятным именем. Это сделает код самодокументируемым: type UserID = string | number гораздо понятнее, чем повторяющийся string | number.
Если вы не знаете тип значения, используйте unknown вместо any. unknown безопаснее — он требует проверки типа перед использованием.
При работе с T | null или T | undefined используйте optional chaining (?.operator) и nullish coalescing (??) для безопасного доступа к свойствам.
Если проверка типа становится сложной, вынесите её в отдельную функцию-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 вопросов