Объединения и пересечения
Объединение — это одно-из и открывает лишь общие члены; пересечение — это всё-сразу и сливает члены объектов. Смешай несовместимые примитивы — получишь never.
Коллега пишет 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 и когда проявляется проблема?
- 01Почему на объединении доступны только общие члены и что открывает остальные?
- 02Объясни через множества значений, почему `A & B` несёт больше членов, но описывает меньше значений.
- 03Как несовместимые типы ведут себя при пересечении и почему случай со свойством объекта — ловушка?
Объединения и пересечения — это два способа компоновать типы, и они противоположны. Объединение A | B — это одно-из: значение, которое является одним из своих членов, открывающее только члены, общие для них всех, поэтому для доступа к остальным понадобится сужение. Пересечение A & B — это всё-сразу: для объектных типов оно сливает каждый член, требуя их все при конструировании; непересекающиеся примитивы вроде string & number молча схлопываются в пустой тип never, и то же происходит по-свойственно, когда объектные типы делят ключ с несовместимыми типами значений. Предпочитайте interface extends для проверяемых иерархий «является» и & для бесконфликтных миксинов. Далее сужение покажет, как проверяющий отслеживает, в какой ветке объединения вы находитесь в каждой точке программы, превращая скрытый член объединения в доступный. Теперь, когда встретишь ошибку «Property does not exist» на объединении, сразу будешь знать: сначала нужно сузить тип — или задуматься, а | ли вообще нужен был.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.