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, которому нельзя присвоить ни одно значение.
Проверьте себя
1. Дана переменная типа string | number. Что можно сделать с ней БЕЗ предварительной проверки typeof?
AВызвать value.toUpperCase()
BВызвать value.toFixed(2)
CВызвать только методы, общие и для string, и для number
DПрисвоить значение переменной типа boolean
2. Чему будет равен тип "foo" & { id: number } с точки зрения TypeScript, если "foo" здесь литеральный строковый тип, пересекаемый с объектным типом?
AТип превращается в never, потому что строка и объект несовместимы
BТип остаётся просто string
CTypeScript выдаёт синтаксическую ошибку — так писать нельзя
DПолучится объект с полем id и строковым литералом одновременно, если это в принципе непротиворечиво