Перейти к содержимому
Теория · Сужение типов · урок 7 из 12

Размеченные объединения

Сортировку на ленте можно сделать ещё проще. При отправке на каждую посылку клеят этикетку с одним словом: «конверт» или «банка». Арке больше не нужно разглядывать посылку и искать приметы. Она читает этикетку и сразу знает, что внутри и в какой жёлоб это отправить.

В программе так же различают объекты. Фигура бывает кругом с радиусом r или квадратом со стороной side. Прочитать side у любой фигуры нельзя:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function area(s: Shape) {
    return s.side ** 2; // ✗
}
TEXT
error TS2339: Property 'side' does not exist on type 'Shape'.
  Property 'side' does not exist on type '{ kind: "circle"; r: number; }'.

У круга нет стороны, а в s может прийти круг. Помогла бы проверка "side" in s, но в TypeScript есть приём удобнее — этикетка в самом объекте. Она уже есть в этом примере: поле kind.

Общее поле с литеральным типом

В каждом варианте объединения объявлено одно и то же поле kind, и у каждого варианта у него свой литеральный тип: у круга "circle", у квадрата "square". Такое объединение называют размеченным объединением, а общее поле — его признаком. Сравнение признака с литералом сужает тип:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function area(s: Shape): number {
    if (s.kind === "circle") {
        return Math.PI * s.r ** 2;
    }
    return s.side ** 2;
}

console.log(area({ kind: "square", side: 3 })); // 9

Давайте разберём пример по строкам:

  1. В type Shape у каждого варианта своё значение kind. Вертикальную черту можно поставить и перед первым вариантом: так длинное объединение удобно записывать столбиком.
  2. s.kind === "circle" сравнивает признак с литералом. Значение "circle" бывает только у круга, поэтому внутри if у s тип круга, и s.r компилируется.
  3. После return остался квадрат, и s.side тоже компилируется.
  4. Программа печатает 9.

Главное здесь — проверка одного поля сужает весь объект. Узнав kind, tsc знает и остальные поля варианта: у круга есть r, у квадрата — side.

switch по признаку

Когда вариантов несколько, признак удобно разбирать в switch. Каждый case сужает объект до своего варианта:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
    }
}

console.log(label({ kind: "circle", r: 2 })); // circle, r = 2

Внутри case "circle" у s тип круга, внутри case "square" — квадрата. TypeScript видит, что два case разобрали все варианты, и выводит для label тип результата string. Программа печатает circle, r = 2.

Этот вопрос часто задают на собеседовании: что такое размеченное объединение и как его сузить. Ответ: у всех вариантов есть общее поле с разными литеральными типами, и проверка этого поля в if или switch сужает объект целиком.

Здесь аналогия с этикеткой перестаёт работать. Настоящую этикетку отправитель может написать с ошибкой, и сортировка поверит ей. Признак проверяет tsc: объект { kind: "triangle", side: 3 } не пройдёт как Shape, потому что "triangle" не подходит к типу "circle" | "square". А при запуске признак — обычное поле объекта, поэтому сравнение s.kind === "circle" работает и в JavaScript.

Частая ошибка

Признаку дали тип string:

TypeScript
type Shape =
    | { kind: string; r: number }
    | { kind: string; side: number };

function area(s: Shape): number {
    if (s.kind === "circle") {
        return Math.PI * s.r ** 2; // ✗
    }
    return 0;
}
TEXT
error TS2339: Property 'r' does not exist on type 'Shape'.

У обоих вариантов признак — любая строка, и сравнение с "circle" ничего не говорит о варианте: строка "circle" подходит обоим. tsc не может сузить объект и не даёт прочитать s.r. Признаку нужны литеральные типы, разные у каждого варианта: kind: "circle" и kind: "square".

Главное

  • В размеченном объединении у всех вариантов есть общее поле-признак, и у каждого варианта в нём свой литеральный тип.
  • Проверка признака в if или switch сужает весь объект, вместе с полями варианта.
  • Признак — обычное поле: оно остаётся в объекте при запуске, и сравнение работает в JavaScript.
  • С признаком типа string сужения не будет: нужны разные литеральные типы.