Глава 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’а, и компилятор идёт по ней след в след, повторяя вашу логику в типах.
И отсюда же — обязанность на этапе проектирования. Сужению нужно, за что зацепиться. Союз из неразличимых форм сузить нечем; союз, где у каждого варианта свой литеральный дискриминант или своё характерное поле, разбирается одной проверкой. Поэтому формы данных стоит делать различимыми нарочно: добавить общее поле-метку с литеральным типом, развести варианты так, чтобы их различал один оператор. Вы проектируете не только данные, но и удобство, с которым по ним потом будет рассуждать компилятор. Доказательство несёт сужение; ваша работа — оставить компилятору доказуемые следы.
Итог
Тип переменной живёт в точке кода, а не в её объявлении: он сужается вслед за каждой проверкой, которую вы пишете, и приведения становятся лишними. Кто исключает варианты проверками вместо того, чтобы кастовать, у того компилятор повторяет логику программы шаг за шагом — и роняет сборку на той ветке, которую вы забыли разобрать.