open atlas
↑ К треку
Система типов TypeScript вглубь TS · 02 · 01

Объединения и пересечения

Объединение — это одно-из и открывает лишь общие члены; пересечение — это всё-сразу и сливает члены объектов. Смешай несовместимые примитивы — получишь never.

TS Junior ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

Коллега пишет type Handler = ClickHandler | KeyHandler, а потом вызывает handler.preventDefault() — метод, который есть только у одного из них. Проверяющий отказывает: «Property ‘preventDefault’ does not exist on type ‘ClickHandler | KeyHandler’». Коллега «чинит» это, переключившись на ClickHandler & KeyHandler. Ошибка ушла, но сломались все места вызова, куда передавали обычный ClickHandler. Операторы выглядят симметрично. На деле они противоположны — и путаница между ними одна из самых частых ошибок типов в реальном коде.

Одно-из против всё-сразу

Прежде чем писать | или &, спросите себя: вызывающий может передать одну из вещей или обязан предоставить все сразу? Ответ определяет нужный оператор — а неверный выбор компилируется без ошибок, молча ломая безопасность типов.

Объединение A | B описывает значение, которое является либо A, либо B. Статически вы не знаете, чем именно, поэтому проверяющий разрешает обращаться только к членам, присутствующим в обоих.

Пересечение A & B описывает значение, которое одновременно является и A, и B. У него есть каждый член обоих типов, ведь оно должно удовлетворять обоим контрактам сразу.

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };

type Shape = Circle | Square;
//   ^? type Shape = Circle | Square

function describe(s: Shape) {
  s.kind;
  // ^? (property) kind: "circle" | "square"   — общий для обоих, OK
  s.radius;
  // Error: Property 'radius' does not exist on type 'Shape'.
  //   Property 'radius' does not exist on type 'Square'.
}

kind есть у обоих членов, поэтому он доступен, а его тип — объединение двух литеральных типов. radius есть только у Circle, поэтому в объединении он недоступен, пока вы не сузите тип (следующий урок). Именно ради этого сужение и существует.

Пересечение сливает члены объектов

Для объектных типов A & B — это новый тип, несущий все свойства обоих. Это естественный способ «добавить поля» к форме.

type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };

type Entity = WithId & WithTimestamps;

const row: Entity = { id: "u1", createdAt: new Date(), updatedAt: new Date() };
//    ^? const row: Entity
// Убери любое поле — и проверяющий ругается:
const bad: Entity = { id: "u1", createdAt: new Date() };
// Error: Property 'updatedAt' is missing in type
//   '{ id: string; createdAt: Date; }' but required in type 'WithTimestamps'.

Если обе стороны объявляют одно и то же свойство с совместимыми типами, результат его сохраняет. Если же типы свойства несовместимы, слитое свойство схлопывается в never — см. ниже.

Почему это работает

Почему «всё-сразу» для пересечения поначалу кажется наоборот: в теории множеств пересечение двух множеств меньше. Но типы TypeScript — это множества значений, а тип-значение вроде { a: number } — это множество всех объектов, у которых есть хотя бы a. Множество объектов, у которых есть хотя бы a и хотя бы b, меньше каждого из них по отдельности — но каждое такое значение несёт больше членов. Меньше значений, больше гарантированных полей. Поэтому A & B — это более узкое множество значений, но более широкий набор членов.

Несовместимые примитивы пересекаются в never

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

type Impossible = string & number;
//   ^? type Impossible = never

type AlsoNever = "a" & "b";
//   ^? type AlsoNever = never

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

const x: string & number = "hi";
// Error: Type 'string' is not assignable to type 'never'.

Опасная версия прячется внутри пересечений объектов. Если два объектных типа делят ключ с непересекающимися типами значений, в never превращается лишь это свойство, а сам объектный тип всё ещё выглядит пригодным:

type A = { status: "active"; v: number };
type B = { status: "archived"; v: number };

type Both = A & B;
//   ^? type Both = A & B
type S = Both["status"];
//   ^? type S = never   — "active" & "archived" невозможно

Both необитаем, но вы это обнаружите, лишь попытавшись сконструировать значение или прочитать status. Это классическая ловушка использования & там, где имелось в виду объединение.

Закончи аналогию

Объединение A | B похоже на параметр функции, принимающий либо кота, либо собаку. Вызывать можно только те методы, которые есть у ___ животных.

Поведение в параметрах функции

Функция, принимающая объединение, обязана обработать каждый его член, поэтому в теле доступны только общие члены. Функцию, принимающую пересечение, проще удовлетворить внутри тела (гарантировано больше членов), но сложнее вызвать (аргумент должен быть и тем, и другим):

declare function logUnion(x: number | string): void;
logUnion(1);        // OK
logUnion("a");      // OK

declare function logBoth(x: { a: 1 } & { b: 2 }): void;
logBoth({ a: 1 });
// Error: Property 'b' is missing in type '{ a: 1; }'
//   but required in type '{ b: 2; }'.
logBoth({ a: 1, b: 2 }); // OK

Обратите внимание на асимметрию присваиваемости: number присваиваем к number | string (часть подходит к «целому-или»), но передать частичный объект в параметр-& нельзя («целое-и» требует всё).

interface extends против A & B

Оба компонуют формы объектов, но они невзаимозаменяемы. interface Child extends Parent {} проверяет конфликты немедленно и выдаёт ошибку при объявлении. Пересечение откладывает проверку и при конфликте молча выдаёт never:

interface Base { v: string }
interface Wrong extends Base { v: number }
// Error: Interface 'Wrong' incorrectly extends interface 'Base'.
//   Types of property 'v' are incompatible. Type 'number' is not assignable to type 'string'.

type AlsoWrong = Base & { v: number };
//   ^? type AlsoWrong = Base & { v: number }   — здесь ошибки нет…
type V = AlsoWrong["v"];
//   ^? type V = never   — …конфликт всплывает лишь при чтении

Предпочитайте extends для номинальных иерархий «является», которые хотите проверять в точке объявления; берите & для смешивания ортогональных, бесконфликтных аспектов (миксины, «добавить timestamps», «добавить id»). Объединения, различающиеся по тегу, вы позже смоделируете как размеченные объединения в этом же юните.

Доберись до нужного члена

1/3
Викторина

Для `type X = { id: string } | { id: string; email: string }` — какое свойство доступно на значении типа X без сужения?

Викторина

Что такое `string & number` в TypeScript и когда проявляется проблема?

Вспомните перед уходом
  1. 01
    Почему на объединении доступны только общие члены и что открывает остальные?
  2. 02
    Объясни через множества значений, почему `A & B` несёт больше членов, но описывает меньше значений.
  3. 03
    Как несовместимые типы ведут себя при пересечении и почему случай со свойством объекта — ловушка?
Итог

Объединения и пересечения — это два способа компоновать типы, и они противоположны. Объединение A | B — это одно-из: значение, которое является одним из своих членов, открывающее только члены, общие для них всех, поэтому для доступа к остальным понадобится сужение. Пересечение A & B — это всё-сразу: для объектных типов оно сливает каждый член, требуя их все при конструировании; непересекающиеся примитивы вроде string & number молча схлопываются в пустой тип never, и то же происходит по-свойственно, когда объектные типы делят ключ с несовместимыми типами значений. Предпочитайте interface extends для проверяемых иерархий «является» и & для бесконфликтных миксинов. Далее сужение покажет, как проверяющий отслеживает, в какой ветке объединения вы находитесь в каждой точке программы, превращая скрытый член объединения в доступный. Теперь, когда встретишь ошибку «Property does not exist» на объединении, сразу будешь знать: сначала нужно сузить тип — или задуматься, а | ли вообще нужен был.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.