Union и intersection типы
«Вот функция принимает string | number. Почему компилятор не даёт вызвать у параметра .toUpperCase() без проверки?» — классический вопрос, который проверяет, понимаешь ли ты, что такое union на самом деле.
Union-тип (
A | B) — значение может быть либо типом A, либо типом B: это тип «или». Intersection-тип (A & B) — значение обязано одновременно соответствовать и A, и B: это тип «и».
Вопрос-крючок
Вот типичный код с union-параметром:
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return id.toFixed(2);
}
console.log(formatId("a1b2"));
console.log(formatId(3.14159));Вывод:
A1B2
3.14Обрати внимание: внутри блока if (typeof id === "string") можно смело вызывать .toUpperCase() — метод строки. А во второй ветке — .toFixed(2), метод числа. Причём если убрать проверку typeof и попытаться вызвать id.toUpperCase() сразу, TypeScript откажется компилировать код — потому что id может оказаться числом, а у чисел такого метода нет. Это и есть суть union — и суть механизма, который называется narrowing.
Union: тип «или»
Union-тип, записанный через вертикальную черту |, говорит: «значение — это одно из перечисленных, но какое именно — заранее не известно». Это как ящик, в который могло положить либо яблоко, либо гвоздь — пока не заглянешь внутрь, нельзя быть уверенным, что там.
type Fruit = "apple" | "banana" | "cherry";
function describeFruit(fruit: Fruit): string {
return "Это фрукт: " + fruit;
}
console.log(describeFruit("banana"));
// describeFruit("potato"); // ошибка компиляции: "potato" не входит в FruitВывод:
Это фрукт: bananaВажное правило: пока TypeScript не знает точно, какой из вариантов union перед ним, он разрешает использовать только то, что общее для всех вариантов сразу. Например, если тип — string | number, то без дополнительных проверок доступны только методы, которые есть и у строки, и у числа одновременно (а таких почти нет). Это защита: компилятор не даст случайно вызвать метод, которого может не быть у одного из вариантов.
Сужение типа (narrowing): как «заглянуть в ящик»
Чтобы получить доступ к специфичным методам, нужно сначала сузить тип — доказать компилятору, какой именно вариант перед тобой сейчас. Самый частый способ — проверка typeof для примитивов:
function double(value: string | number): string | number {
if (typeof value === "number") {
return value * 2;
}
return value + value;
}
console.log(double(5));
console.log(double("ha"));Вывод:
10
hahaВнутри блока после проверки typeof value === "number" TypeScript сам понимает, что дальше по коду value точно число, и разрешает value * 2. А в ветке после return он «сужает» тип до оставшегося варианта — string — автоматически, без дополнительных проверок, потому что других вариантов у union не осталось.
Для объектных типов вместо typeof часто используют проверку наличия поля через in или сравнение общего поля-метки (discriminated union):
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
return shape.side ** 2;
}
console.log(area({ kind: "circle", radius: 2 }).toFixed(2));
console.log(area({ kind: "square", side: 3 }));Вывод:
12.57
9Поле kind тут — «дискриминант»: общее поле-метка с разными литеральными значениями в каждом варианте union. Проверка shape.kind === "circle" сужает тип до Circle во всём блоке — и тогда shape.radius становится доступным без ошибок, потому что компилятор точно знает, какой вариант перед ним.
Intersection: тип «и»
Пересечение через & работает наоборот — значение обязано подходить под оба типа сразу, со всеми полями обоих:
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge;
const p: Person = { name: "Оля", age: 19 };
console.log(p.name, p.age);Вывод:
Оля 19Если хоть одно поле пропустить — { name: "Оля" } без age — компилятор откажется присваивать такой объект переменной типа Person, потому что он не удовлетворяет части HasAge. Intersection часто путают с union именно из-за похожих символов, но смысл у них противоположный: union расширяет набор допустимых форм («любая из этих»), а intersection сужает его до пересечения требований («обязательно все эти сразу»).
Как это работает под капотом
С точки зрения компилятора разница между union и intersection — это разница между объединением и пересечением множеств допустимых значений:
| Конструкция | Что значит | Доступ без проверки |
A | B | Значение — A или B | Только общее для A и B |
A & B | Значение — и A, и B одновременно | Всё из A и всё из B |
Для примитивов (string | number) сужение union происходит через typeof, для классов — через instanceof, для объектов с общим полем-меткой — через сравнение этого поля (discriminated union) или оператор in. Компилятор в каждом случае отслеживает поток управления (control flow analysis) и «помнит» внутри каждого блока кода, какой именно вариант union доказан на этом участке.
Частые ошибки на собеседовании
- Путают направление сужения union и intersection. Кандидат говорит «pipe (|) сужает тип» — на самом деле наоборот: union расширяет набор допустимых значений, а сужает его именно проверка внутри кода (narrowing), а не сам оператор |.
- Забывают, что
string & number— это тип, которому нельзя присвоить вообще ничего. Пересечение двух непересекающихся примитивов даётnever— значение должно быть одновременно строкой и числом, а таких значений не существует. - Пытаются обратиться к специфичному полю union без сужения. Например, читают
shape.radiusу переменной типаCircle | Squareбез предварительной проверкиkind— компилятор справедливо ругается, ведь у Square поля radius нет. - Не используют discriminated union там, где он напрашивается. Вместо общего поля-метки городят цепочки
in-проверок или приведение типа черезas, хотя один литеральный дискриминант сделал бы код короче и безопаснее.
Итоги
- Union (
|) — тип «или»: значение соответствует одному из перечисленных вариантов, но заранее не известно, какому именно. - Intersection (
&) — тип «и»: значение обязано соответствовать всем перечисленным типам сразу, объединяя их поля. - Без дополнительных проверок с union доступно только общее для всех вариантов; чтобы получить специфичные поля и методы, тип нужно сузить.
- Сужение (narrowing) делается через typeof, instanceof, in или сравнение поля-дискриминанта — и компилятор отслеживает это по ходу кода.
- Пересечение несовместимых примитивов (например, string & number) даёт тип never, которому нельзя присвоить ни одно значение.