← Глава 7. Суммы и произведения Содержание Глава 9. Размеченные объединения →

Глава 8. Литеральные типы и сужение

Глава 8. Литеральные типы и сужение

Откуда он знает, что тут уже не null

Вот функция, которая берёт заказ, не найденный в базе как null, и хочет напечатать его номер.

type Order = { id: string; total: number };

function findOrder(id: string): Order | null {
  // ...поиск, который может ничего не вернуть
  return null;
}

function printId(id: string): void {
  const order = findOrder(id); // тип: Order | null
  console.log(order.id);       // ошибка: 'order' возможно null
}

Компилятор отказывается читать order.id: вдруг order — это null, и тогда обращение к полю упадёт в рантайме. Разумно. Добавим проверку.

function printId(id: string): void {
  const order = findOrder(id); // тип: Order | null
  if (order !== null) {
    console.log(order.id);     // ошибки нет; order.id — это string
  }
}

Красное подчёркивание исчезло. Внутри if к order.id можно обращаться свободно — и не потому, что мы что-то приписали, привели или развернули. Мы ничего не сделали с переменной. Мы только спросили, не null ли она, и компилятор, увидев этот вопрос, изменил своё мнение о типе order внутри ветки: снаружи if это Order | null, внутри — уже Order. Та же переменная, в разных точках кода — разный тип.

Рефлекс, привезённый из статически типизированного мира, велит на этом месте написать что-то вроде «приведи order к Order, раз ты убедился, что это не null». В TypeScript приводить нечего и незачем. Проверку, которую вы написали ради рантайма, проверяющий тоже прочитал — и сам сделал из неё вывод о типе. Каст оказался бы лишним движением: компилятор уже там, где вы хотели его привести.

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

Литеральные типы — атомы алгебры

Прежде чем разбирать, как тип сужается, нужно вещество, которое можно сужать. В главе 6 тип был множеством значений, а в главе 7 из множеств складывались суммы. Самые мелкие из этих множеств — одноэлементные.

type Yes = true;        // множество из одного значения: true
type Answer = 42;       // множество из одного числа: 42
type Method = "GET";    // множество из одной строки: "GET"

"GET" — это не string. Это тип, которому принадлежит ровно одно значение, строка "GET", и больше ничего. Такие типы называют literal types — литеральные типы; их населяет единственный литерал. Сами по себе они малоинтересны: переменная, которая может быть только "GET", бесполезна. Польза появляется, когда их складывают в сумму и получают тип, нарезающий бесконечный string на горстку осмысленных вариантов.

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";

function request(method: HttpMethod): void { /* ... */ }

request("GET");   // годится
request("PATCH"); // ошибка: '"PATCH"' не входит в HttpMethod

Это и есть статус заказа из сквозного примера. Не string, в который может прийти что угодно, а замкнутое множество из пяти значений:

type OrderStatus =
  | "created"
  | "paid"
  | "shipped"
  | "delivered"
  | "cancelled";

type Order = {
  id: string;
  total: number;
  status: OrderStatus;
  items: { sku: string; qty: number }[];
};

Литеральные типы — это атомы, из которых собрана алгебра значений: кирпичи union’ов и, как мы увидим в главе 9, опоры для проверки исчерпанности. Но у них есть характер, и про него стоит вспомнить сразу, потому что он будет мешать сужению дальше. По умолчанию компилятор не хочет считать значение литеральным — он его расширяет (widening). Об этом была глава 4; здесь достаточно увидеть симптом.

let m = "GET";          // тип: string, а не "GET"
const c = "GET";        // тип: "GET"

const obj = { method: "GET" };
obj.method;             // тип: string, а не "GET"

let m = "GET" даёт string, а не "GET": переменную, объявленную через let, можно переприсвоить, поэтому компилятор берёт широкий тип. const c = "GET" присвоить заново нельзя — и тип остаётся узким, литеральным. А свойство объекта расширяется даже при const на самом объекте, потому что поле объекта изменяемо. Чтобы запретить расширение и зафиксировать литералы, есть as const:

const obj = { method: "GET" } as const;
obj.method;             // тип: "GET"

const methods = ["GET", "POST"] as const;
// тип: readonly ["GET", "POST"], а не string[]

as const говорит проверяющему: бери всё внутри максимально узко и не давай это менять. Литералы остаются литералами, массив становится readonly-кортежем фиксированной длины. Запомним этот инструмент — он понадобится, когда расширение начнёт воевать с сужением.

Тип в точке кода, а не в объявлении

Вернёмся к тому, что компилятор сделал с order в первом примере. Он не один раз посчитал тип переменной в месте объявления и забыл про неё. Он ведёт тип в каждой точке программы отдельно и пересчитывает его всякий раз, когда поток управления проходит через что-то, что про эту переменную что-то сообщает. Этот механизм называется control flow analysis — анализ потока управления, а сам приём, когда тип в точке кода уже, чем в объявлении, — narrowing, сужение.

Посмотрим на него в чистом виде, без null.

function format(value: string | number): string {
  // здесь value: string | number
  if (typeof value === "number") {
    // здесь value: number
    return value.toFixed(2);
  }
  // а здесь value: string — про number мы уже вышли через return
  return value.toUpperCase();
}

Три точки кода — три разных типа у одной переменной value. В начале функции это string | number. Внутри if проверка typeof value === "number" отсекла строку, остался number, и toFixed доступен. После if интереснее: туда поток управления попадает только если условие было ложным — но из истинной ветки мы вышли через return. Значит, в этой точке value числом быть не может, и его тип — string, безо всякой проверки на строку. Компилятор вычел number из union’а, потому что проследил, какими путями выполнение сюда доходит.

Именно это делает сужение не косметикой, а рассуждением. Тип после ветвления зависит от того, что произошло в ветвлении — был ли return, throw, continue, присваивание. Поменяйте return на что-то, не прерывающее поток, и картина изменится:

function format(value: string | number): string {
  let result: string;
  if (typeof value === "number") {
    result = value.toFixed(2);  // value: number
  }
  // value снова string | number: из if мы не вышли,
  // сюда поток попадает в обоих случаях
  return value.toUpperCase();   // ошибка: toUpperCase нет у number
}

Без return обе ветви сливаются обратно, тип value после if снова string | number, и toUpperCase уже нельзя — у числа его нет. Сужение действует ровно там, где компилятор может доказать, каким значение пришло. Уходит доказательство — уходит и сужение.

То же самое работает не только внутри функции, но и на её аргументах, и на полях, и на возврате других функций. Тип — это не бирка, прибитая к переменной при рождении. Это текущее знание проверяющего о ней в конкретной точке, и оно живёт и меняется вдоль кода.

Чем сужают

Раз тип двигают проверки, стоит знать арсенал — какими конструкциями компилятор умеет сужать и что каждая отсекает. Все они — настоящие операторы JavaScript: сужение читает тот же код, который исполнится в рантайме, и делает из него типовой вывод.

typeof — для примитивов. Возвращает одну из рантайм-категорий ("string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint"), и компилятор сопоставляет её с членами union’а.

function len(x: string | string[]): number {
  if (typeof x === "string") return x.length;   // x: string
  return x.length;                              // x: string[]
}

Проверка на истинность (truthiness) отсекает null, undefined, 0, "", NaN, false — всё, что в JavaScript ложно.

function printId(order: Order | null): void {
  if (order) {
    console.log(order.id);   // order: Order
  }
}

Равенство — ===, !== и их нестрогие версии — сужает по конкретному значению, особенно к литеральным типам.

function label(s: OrderStatus): string {
  if (s === "cancelled") {
    return "Отменён";        // s: "cancelled"
  }
  return "Активен";          // s: остальные четыре статуса
}

in проверяет наличие свойства и так различает формы объектов.

type Paid = { status: "paid"; paidAt: number };
type Shipped = { status: "shipped"; trackingNo: string };

function track(o: Paid | Shipped): string {
  if ("trackingNo" in o) {
    return o.trackingNo;     // o: Shipped
  }
  return "ещё не отправлен"; // o: Paid
}

instanceof сужает по цепочке прототипов — для значений, у которых есть конструктор.

function toMessage(e: Error | string): string {
  if (e instanceof Error) return e.message; // e: Error
  return e;                                  // e: string
}

Проверка поля-дискриминанта — частный случай равенства, но достаточно важный, чтобы назвать отдельно. Когда у каждого члена union’а есть общее поле с разными литеральными значениями, проверка этого поля сужает весь объект до одного варианта. Это сердце discriminated union — размеченного объединения, которому посвящена глава 9; здесь — превью.

type Order =
  | { status: "created"; total: number }
  | { status: "shipped"; total: number; trackingNo: string };

function info(o: Order): string {
  if (o.status === "shipped") {
    return o.trackingNo;     // o сужен до второго варианта
  }
  return "не отправлен";     // o: первый вариант
}

Присваивание тоже сужает. Записали в переменную значение известного типа — и дальше компилятор считает её типом тип присвоенного, а не объявленного.

let value: string | number;
value = "A-1001";
value.toUpperCase();   // value: string — сужено присваиванием
value = 42;
value.toFixed(2);      // value: number — тип переехал следом

Общий принцип один: компилятор отслеживает любую конструкцию, которая в рантайме различает значения, и переносит это различение в типы. Чем определённее проверка, тем уже результат.

Сужение до never

Что произойдёт, если проверками исключить из union’а все варианты до единого? Останется тип, у которого нет ни одного значения, — never, нижний тип, пустое множество. Это не ошибка и не вырожденный случай: это полезный сигнал, и на нём держится целый приём.

function describe(s: "paid" | "shipped"): string {
  if (s === "paid") return "Оплачен";
  if (s === "shipped") return "Отправлен";
  // сюда поток дойти не может: оба варианта исчерпаны
  s; // тип: never
  return s;
}

В последней точке s имеет тип never: и "paid", и "shipped" отсечены, в union’е не осталось ни одного члена. Компилятор согласен, что сюда выполнение не попадёт. Само по себе это любопытство — но превращается в инструмент, когда мы заставляем never сторожить полноту разбора. Присвоить что-либо переменной типа never нельзя: в пустое множество не входит ни одно значение.

function assertNever(x: never): never {
  throw new Error("Необработанный вариант: " + String(x));
}

function describe(s: OrderStatus): string {
  switch (s) {
    case "created":   return "Создан";
    case "paid":      return "Оплачен";
    case "shipped":   return "Отправлен";
    case "delivered": return "Доставлен";
    case "cancelled": return "Отменён";
    default:
      // сюда s приходит как never, если разобраны все статусы
      return assertNever(s);
  }
}

Пока switch покрывает все пять статусов, в default переменная s сужена до never, и вызов assertNever(s) проходит проверку. А теперь добавьте в OrderStatus шестой статус "refunded" и не тронете switch — компилятор немедленно подсветит ошибку:

default:
  return assertNever(s);
  // ошибка: аргумент типа '"refunded"' не сопоставим с 'never'

Случай "refunded" не разобран, поэтому в default он доживает до вызова, s там уже "refunded", а не never, и assertNever его не принимает. Один невнятный на первый взгляд тип превратился в проверку: компилятор доказывает, что вы разобрали все ветви, и роняет сборку в тот день, когда вариантов стало больше, а код за ними не угнался. Полную механику этого приёма разберём в главе 9; здесь важно увидеть, что сужение до пустого множества — не тупик, а зерно доказательства полноты.

Глазами джависта

В Java эту бухгалтерию вы ведёте руками — и долго не замечали, что ведёте. Получив Object, вы проверяете тип и приводите:

Object value = ...;
if (value instanceof String) {
    String s = (String) value;   // проверили — теперь приводим
    return s.toUpperCase();
}

Проверка instanceof и каст (String) — два отдельных действия, и компилятор не связывает их сам: вы убедились, что это String, а потом ещё раз сообщаете об этом компилятору приведением. Свежие версии Java сшили их через pattern matching: if (value instanceof String s) вводит переменную s сразу нужного типа. Но обратите внимание — это новая переменная. Исходная value так и осталась Object; уточнения той же переменной на месте, как в TypeScript, нет.

И уточнять, по сути, нечего: у Java нет union-типов, а значит, нет и того, что TypeScript сужает в первую очередь. String | number в Java не выражается; ближайшее — общий супертип вроде Object или запечатанная иерархия (sealed), и работа с ней — это спуск по дереву наследования через instanceof, а не вычитание членов из суммы. Литеральных типов нет вовсе: "GET" — это значение, но не тип, и "GET" | "POST" записать невозможно; ближайшая замена — enum, отдельный номинальный механизм.

TypeScript забирает всю эту ручную работу себе. Вы пишете проверку — ту же if (typeof x === ...), которая всё равно нужна в рантайме, — и компилятор уточняет ту же переменную, без второй переменной и без каста. Касты в TypeScript есть (as, глава 1), но появляются они там, где вы лжёте проверяющему, а не там, где честно его убедили. Привычка после instanceof приписать приведение здесь становится лишней: проверка и есть приведение, выполненное за вас.

Где сужение слетает

Сужение убедительно ровно настолько, насколько компилятор способен доказать, что между проверкой и использованием значение не подменили. Где доказательство рушится, рушится и сужение — и эти места надо знать поимённо, потому что выглядят они безобиднее, чем работают.

Первое и самое частое — замыкания и колбэки. Если переменная объявлена через let и где-то в функции ей присваивают заново, сужение теряется внутри вложенной функции: компилятор не может доказать, что к моменту её отложенного вызова переменная всё ещё та, какой была на момент проверки.

function handle(order: Order | null): void {
  let current = order;
  if (current === null) return;
  current.id;                     // здесь current: Order — всё хорошо

  setTimeout(() => {
    current.id;                   // ошибка: 'current' возможно null
  }, 1000);

  current = order;                // переприсваивание где-то ниже
}

Снаружи колбэка current сужен до Order, но колбэк выполнится позже; а поскольку current — это let, которому ниже по коду присваивают заново, компилятор не может ручаться, что к моменту вызова колбэка там по-прежнему Order. Он перестраховывается и возвращает исходный тип Order | null. Уберите присваивание current = order — и ошибка исчезнет: переменную, которую нигде не переприсваивают, компилятор считает стабильной и сохраняет сужение даже в замыкании. Поэтому лечится это тем, что мешает переприсваиванию: const для самой переменной либо ранний выход с присваиванием в новую константу.

Второе — алиасинг и мутация свойств. Здесь сужение слетает не на этапе компиляции, а в рантайме, и это опаснее: компилятор сужение поля сохраняет, хотя сохранять его не вправе.

type Box = { value: string | null };

function evil(b: Box): void { b.value = null; }

function use(box: Box): void {
  if (box.value !== null) {
    evil(box);                    // внутри box.value = null
    box.value.toUpperCase();      // ошибки компиляции нет — а в рантайме падёт
  }
}

После проверки box.value !== null компилятор считает box.value строкой и держится этого мнения, не отслеживая, что evil(box) присвоило полю null через ту же ссылку. Ошибки нет, а в рантайме box.value.toUpperCase() упадёт на null. Это не перестраховка, а несостоятельность (unsoundness, глава 1): TypeScript не моделирует алиасинг и не пересчитывает сужение поля после чужих вызовов, поэтому сужение свойства держится дольше, чем оно истинно. Сужение переменной надёжнее: переменную видно целиком, поле — через ссылку, которую кто-то ещё мог держать и менять. Поэтому надёжный приём — вытащить значение в локальную константу до вызова: const v = box.value; if (v !== null) { evil(box); v.toUpperCase() }. Локальная v никому не видна снаружи, и её evil подменить не может.

Третье — дырявое typeof. Историческая особенность JavaScript: typeof null === "object". Поэтому проверка typeof x === "object" не отсекает null, и сужение протекает.

function f(x: object | null): void {
  if (typeof x === "object") {
    x; // тип: object | null — null просочился, как и в рантайме
  }
}

Компилятор здесь честен до неприятного: он воспроизводит реальное поведение typeof, а не идеальное. Сужать object-или-null надо проверкой на истинность (if (x)) или прямым x !== null, а не через typeof.

Четвёртое — расширение воюет с сужением. Литералы, ради которых вы строили union, расширяются при первом удобном случае, и тогда сужать оказывается нечего.

function send(method: "GET" | "POST"): void { /* ... */ }

const config = { method: "GET" };  // method расширен до string
send(config.method);               // ошибка: string не входит в union

config.method получил тип string, а не "GET", потому что поле изменяемо. Лекарство — as const на объекте, чтобы литерал остался литералом. Это та самая стычка из второй секции: union из литералов бесполезен, если значения до него не доходят литеральными.

Пятое — обычная булева функция не сужает. Вынесите проверку в функцию, возвращающую boolean, и сужение пропадёт: компилятор видит лишь boolean, а не то, что эта истинность сообщает о типе аргумента.

function isPresent(x: unknown): boolean {
  return x !== null && x !== undefined;
}

function g(order: Order | null): void {
  if (isPresent(order)) {
    order.id; // ошибка: 'order' возможно null — boolean ничего не сузил
  }
}

Чтобы функция сужала, её возврат должен быть не boolean, а предикатом типа — x is T, type guard, пользовательский охранник типа. Это мост между рантайм-проверкой и знанием проверяющего, и ему отведена глава 25. Пока достаточно увидеть границу: встроенные конструкции сужают сами, своя проверка — только если вы явно научите её этому.

Ни одна из этих протечек — не каприз. Все они растут из одного корня: компилятор сужает по тому коду, который видит, и ровно настолько, насколько уверен, что значение между проверкой и использованием не сменилось. Где он не может за это поручиться — с замыканием над переменной, которую переприсваивают, — он перестраховывается и сужение снимает. Где не отслеживает вовсе — с алиасингом свойства — он, наоборот, сужение сохраняет, хотя зря, и протечка всплывает уже в рантайме. Сужение — это рассуждение, и оно настолько надёжно, насколько надёжна цепочка, по которой компилятор его вёл.

Не приводи — проверяй, и пусть тип следует за рассуждением

Сдвиг в голове тут такой. Вы привыкли, что тип переменной задан один раз и навсегда в месте, где она объявлена, а всё несовпадение между объявленным и фактическим закрываете приведением: «я-то знаю, что здесь строка, — вот, кастую». Тип статичен, а каст — это вы, доплачивающий компилятору за то, что знаете больше него.

В TypeScript тип динамичен вдоль кода, и доплачивать не нужно. Знание, которое вы собирались выразить кастом, уже выражено самой проверкой, которую вы всё равно пишете ради рантайма. Не нужно убеждать компилятор отдельным жестом, что после if (order !== null) перед вами Order: он прочитал это if раньше вас. Каст в этом мире — не инструмент уточнения, а признание, что вы обходите проверяющего (через as), и появляться он должен только там, где вы сознательно берёте ответственность на себя, а не там, где честно сузили.

Из этого следует способ писать код. Стройте ветвление так, чтобы оно последовательно исключало варианты, и тип сам ужмётся под ваше рассуждение: проверили null — ушёл null; спросили дискриминант — остался один вариант; разобрали все — упёрлись в never, и компилятор подтвердил, что разбор полон. Программа становится цепочкой вычитаний из union’а, и компилятор идёт по ней след в след, повторяя вашу логику в типах.

И отсюда же — обязанность на этапе проектирования. Сужению нужно, за что зацепиться. Союз из неразличимых форм сузить нечем; союз, где у каждого варианта свой литеральный дискриминант или своё характерное поле, разбирается одной проверкой. Поэтому формы данных стоит делать различимыми нарочно: добавить общее поле-метку с литеральным типом, развести варианты так, чтобы их различал один оператор. Вы проектируете не только данные, но и удобство, с которым по ним потом будет рассуждать компилятор. Доказательство несёт сужение; ваша работа — оставить компилятору доказуемые следы.

Итог

Тип переменной живёт в точке кода, а не в её объявлении: он сужается вслед за каждой проверкой, которую вы пишете, и приведения становятся лишними. Кто исключает варианты проверками вместо того, чтобы кастовать, у того компилятор повторяет логику программы шаг за шагом — и роняет сборку на той ветке, которую вы забыли разобрать.

← Глава 7. Суммы и произведения Содержание Глава 9. Размеченные объединения →