Глава 3. Стирание: почему типов нет в рантайме
Глава 3. Стирание: почему типов нет в рантайме
Куда делся тип
Рано или поздно каждый, кто пришёл в TypeScript из языка с честными рантайм-типами, пишет что-нибудь в таком духе. Нужна универсальная фабрика: дай ей тип — она вернёт свежий экземпляр.
function make<T>(): T {
return new T(); // ошибка: 'T' only refers to a type, but is being used as a value here
}
Компилятор отвергает это сразу, и формулировка точная: T — это только тип, а вы пытаетесь использовать его как значение. new хочет конструктор — настоящий объект, существующий в рантайме, у которого есть прототип и которого можно вызвать. А T — не объект. Это имя, которое существовало ровно в голове у проверяющего и было стёрто до того, как new дошёл бы до исполнения. Вызывать нечего.
Та же стена встаёт всюду, где хочется спросить у рантайма про тип-параметр.
function isT<T>(value: unknown): value is T {
return value instanceof T; // ошибка: 'T' only refers to a type
}
interface Order { id: string; total: number }
function handle(x: unknown) {
if (x instanceof Order) {} // ошибка: 'Order' only refers to a type
}
instanceof T невозможен по той же причине, что и new T(): справа от instanceof обязан стоять конструктор, а T к рантайму не существует. instanceof Order невозможен ещё нагляднее — Order объявлен через interface, у него никогда и не было рантайм-воплощения, проверять прототип не у чего.
Глава 1 установила сам факт: типы стираются, в исполняемом .js их нет. Этого достаточно, чтобы перестать ждать от аннотации рантайм-защиты. Но недостаточно, чтобы работать. Потому что на практике вопрос звучит конкретнее: что именно исчезает, а что переживает компиляцию? Где проходит линия между «это можно спросить в рантайме» и «это уже стёрто»? И если ответ для какого-то случая «стёрто» — что делать тому, кому форма значения нужна не на этапе проверки, а во время исполнения?
Эта глава — точная опись стирания. Не «типов нет» как лозунг, а карта: что доживает до рантайма, что нет, где в самом стирании остались дыры, и какой образ мышления из всего этого следует.
Что стирается, а что остаётся
Начнём с правила, а потом проверим его на каждой конструкции. Стирается ровно то, что адресовано только проверяющему. Остаётся ровно то, что является настоящим JavaScript. Линия проходит точно здесь, и ни один пример её не нарушит.
Возьмём заказ — предметную область, к которой мы возвращаемся через всю книгу, — и нагрузим один файл всем типовым арсеналом сразу.
// order.ts
type OrderId = string;
interface Order {
id: OrderId;
total: number;
items: { sku: string; qty: number }[];
}
function firstSku<T extends Order>(order: T): string {
const head = order.items[0]!; // ! — non-null assertion
return head.sku as string; // as — type assertion
}
const sample = { id: "A-1", total: 500, items: [{ sku: "BOOK", qty: 1 }] } satisfies Order;
console.log(firstSku(sample));
Здесь собрано почти всё, что джавист принял бы за «настоящий код, который что-то делает в рантайме»: псевдоним типа, интерфейс, дженерик с ограничением extends, утверждение non-null !, утверждение типа as, и оператор satisfies. Прогоним через компилятор и посмотрим, что он реально выдаёт на выходе.
// order.js — то, что исполняется
function firstSku(order) {
const head = order.items[0];
return head.sku;
}
const sample = { id: "A-1", total: 500, items: [{ sku: "BOOK", qty: 1 }] };
console.log(firstSku(sample));
Сверим построчно. Исчезли:
type OrderId— псевдоним. Не оставляет ни байта; это имя для проверяющего, и только.interface Order— целиком. Интерфейс не порождает в рантайме ничего: ни объекта, ни функции, ни константы.<T extends Order>— параметр типа и его ограничение. Функция стала обычнойfirstSku(order), без всякого следаT.!вorder.items[0]!— non-null assertion. Это утверждение «здесь неnullи неundefined», адресованное проверяющему; в рантайме не делает ничего и потому исчезает целиком, оставляя голыйorder.items[0].as string— type assertion. Переписывало убеждение проверяющего, в рантайме не значило ничего, стёрто.satisfies Order— проверилось (формаsampleсверена сOrder) и испарилось. От него в.jsнет следа; см. ниже, чем оно отличается отas.
Не исчезло — потому что это настоящий JavaScript: тела функций, обращения к свойствам, литерал объекта sample, вызов console.log. Движку отдают именно их, и он гоняет их по правилам JS, не подозревая, что когда-то рядом стояли типы.
Стоит задержаться на satisfies, потому что его легко спутать с as, а разница ровно по линии честности. as Order говорит проверяющему «считай, что это Order» — и не проверяет ничего; вы можете утвердить заведомую ложь. satisfies Order говорит «проверь, что это совместимо с Order, но не меняй выведенный тип» — и если форма не сойдётся, будет ошибка компиляции.
const bad = { id: "A-1", total: 500 } satisfies Order;
// ошибка: Property 'items' is missing in type ... but required in type 'Order'
satisfies — это утверждение, которое проверяется. as — утверждение, которому верят на слово. Но в рантайме их судьба одинакова: оба стираются дочиста. satisfies отрабатывает на этапе проверки и исчезает; никакой рантайм-валидации он не добавляет, как не добавляет её ничто из перечисленного выше.
Запомним опись. Стираются: аннотации типов, type, interface, параметры дженериков и их ограничения, as, !, satisfies. Остаётся: всё, что было бы валидным JavaScript и без TypeScript. Правило держится: к проверяющему — стирается, к движку — доживает.
Дыры в стирании
Правило красивое, и хочется, чтобы оно было без исключений: «типовое — стирается, рантаймовое — остаётся, граница идеальна». Но честнее сразу показать, где TypeScript свою же границу нарушает. Есть конструкции, которые объявлены в типовом синтаксисе, а в рантайм всё-таки порождают код. Их немного, и за каждой стоит ранняя ошибка дизайна, которую язык теперь пытается изжить.
enum. Самая известная дыра. enum выглядит как объявление типа, но компилируется в реальный объект.
enum Status {
Created,
Paid,
Shipped,
}
// в .js появляется живой объект — это НЕ стёрто
var Status;
(function (Status) {
Status[Status["Created"] = 0] = "Created";
Status[Status["Paid"] = 1] = "Paid";
Status[Status["Shipped"] = 2] = "Shipped";
})(Status || (Status = {}));
Из одной типовой на вид конструкции вырос рантайм-объект с двусторонним отображением (имя→число и число→имя). Это нарушает правило «типовое стирается»: enum Status доживает до рантайма как Status. Именно поэтому сообщество давно предпочитает union литералов — type Status = "created" | "paid" | "shipped" — который стирается полностью и в рантайме оставляет обычные строки.
namespace с телом — тоже порождает объект-обёртку в рантайме, а не только тип. Параметры-свойства в конструкторе — сокращение, которое выглядит как аннотация, но генерирует присваивание:
class OrderService {
constructor(private readonly repo: Repo) {} // 'private' тут не только тип
}
class OrderService {
constructor(repo) {
this.repo = repo; // присваивание сгенерировано из параметра-свойства
}
}
Модификаторы private readonly сами по себе — типовые, они стёрлись бы. Но синтаксис параметра-свойства просит компилятор ещё и сгенерировать this.repo = repo. Объявление в типовом месте порождает рантайм-инструкцию.
Самая глубокая дыра — метаданные декораторов. При experimentalDecorators и emitDecoratorMetadata компилятор не просто оставляет код декоратора, а записывает информацию о типах в рантайм — через Reflect.metadata. Тип параметра конструктора, стёртый везде, здесь сохраняется как рантайм-значение.
@Injectable()
class OrderService {
constructor(private repo: OrderRepo) {}
// emitDecoratorMetadata запишет в метаданные: тип первого параметра — OrderRepo
}
Это единственное место, где аннотация типа целенаправленно доходит до рантайма. На нём держится целый пласт экосистемы: dependency injection в NestJS и Angular, маппинг сущностей в TypeORM — все они в рантайме читают метаданные и узнают, какой класс надо подставить, именно из стёртого, казалось бы, типа параметра. Это работает, на этом построены большие фреймворки, и это прямое противоречие тезису «типов в рантайме нет».
Важно, как сама команда TypeScript относится к этим дырам: как к ошибкам. Не как к фичам, которые надо беречь, а как к историческому долгу, который надо погашать. Доказательство — направление движения экосистемы. Стандартные декораторы ECMAScript (в отличие от legacy) метаданные типов не эмитят. Нативное стирание типов в Node и в браузерах умеет отрывать только то, что чисто типовое, — а enum, namespace с телом и параметры-свойства оно исполнять отказывается, потому что они не стираемы. И TypeScript добавил флаг, который запрещает их прямо на этапе проверки:
tsc --erasableSyntaxOnly # запретить всё, что не стирается дочиста
enum Status { Created } // ошибка: This syntax is not allowed when 'erasableSyntaxOnly' is enabled
Флаг существует ровно затем, чтобы привести язык в соответствие с его же принципом: код на TypeScript должен превращаться в JavaScript вычёркиванием, без генерации. Дыры — это не доказательство, что «на самом деле типы остаются». Это признанные нарушения правила, которые язык выдавливает из себя. Правило — стирание; enum и компания — отступления от него, и стрелка истории направлена на их устранение.
Что переживает стирание — то и можно проверить в рантайме
Перевернём вопрос. Раз типовое исчезает, а настоящий JavaScript остаётся, то список «на что можно опереться в рантайме» — это в точности список конструкций JS, существующих без всякого TypeScript. Их немного, и они образуют весь арсенал рантайм-проверок.
typeof value === "string" // одна из горстки рантайм-категорий JS
value instanceof Date // проверка цепочки прототипов
"total" in value // есть ли такой ключ
Array.isArray(value) // массив ли это
value.status === "paid" // значение реально присутствующего поля
Каждая из этих проверок переживает стирание, потому что стирать в ней нечего: все они — операторы и вызовы самого JavaScript. typeof спрашивает у движка одну из фиксированного набора категорий ("string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint"). instanceof ходит по прототипам реального объекта-конструктора. in смотрит, есть ли ключ. Array.isArray — обычная функция. А последняя строка — самая важная: она читает значение реально существующего поля. Никакого типа тут не спрашивают; спрашивают строку, лежащую в объекте.
И вот здесь стирание разворачивается из ограничения в инструмент. Проверяющий не может опереться на тип в рантайме — типа там нет. Но он умеет опереться на то, что пережило стирание, и подтянуть свой вывод к результату рантайм-проверки. Это и есть narrowing — сужение типа: проверяющий следит за рантайм-проверками в коде и в каждой ветке уточняет, что знает о значении.
function totalOf(value: unknown): number {
// здесь value: unknown — про него ничего не известно
if (typeof value === "object" && value !== null && "total" in value) {
// здесь проверяющий сузил: value — объект, у которого есть ключ 'total'
const t = (value as { total: unknown }).total;
if (typeof t === "number") {
return t; // здесь t: number — гарантировано рантайм-проверкой
}
}
throw new Error("no total");
}
Проверяющий не угадывал. Он прочитал typeof t === "number" — настоящую рантайм-проверку, пережившую стирание, — и в ветке, куда поток управления попадает только при её истинности, разрешил себе считать t числом. Статический вывод привязан к динамической проверке. Это работает только потому, что проверка реальна в рантайме; на стёртом типе так опереться было бы не на что.
Отсюда тянется мост ко всей механике сужения и пользовательским type guard’ам (Глава 25): они без остатка построены на том, что пережило стирание. Type guard — это функция, которая делает рантайм-проверку и сообщает её результат проверяющему через специальный возвращаемый тип value is T.
function isOrder(value: unknown): value is Order {
return (
typeof value === "object" && value !== null &&
"id" in value && "total" in value && "items" in value
);
}
Внутри — только то, что доживает до рантайма: typeof, in, сравнение. Снаружи — обещание проверяющему: если функция вернула true, считай значение Order. Это и есть единственный легальный способ навести мост между двумя программами из главы 1 — не через стёртый тип, а через реальную проверку, которую проверяющий соглашается признать. Всё, что можно проверить в рантайме, — это ровно то, что пережило стирание, и ни байтом больше.
Дженерики стираются полностью
Дженерики стоит вынести отдельно, потому что именно на них чаще всего спотыкается интуиция, и потому что они — самый чистый случай полного стирания. Параметр типа исчезает целиком: после компиляции Array<string> и Array<number> — один и тот же Array.
const a: Array<string> = ["x"];
const b: Array<number> = [1];
console.log(a.constructor === b.constructor); // true — это один и тот же Array
В рантайме нет «массива строк» и «массива чисел». Есть Array, в одном лежат строки, в другом числа, и отличить их можно только заглянув внутрь — то есть проверив значения, а не тип. Сам параметр <string> стёрт без остатка.
Из полного стирания дженериков следует список невозможностей, и каждая упирается в одну и ту же пустоту.
class Box<T> {
constructor(public value: T) {}
}
function unbox<T>(box: Box<T>): T {
return box.value;
}
// нельзя инстанцировать параметр типа:
function create<T>(): T {
return new T(); // ошибка: 'T' only refers to a type
}
// нельзя проверить параметр-аргумент через instanceof:
function isStringBox(box: Box<unknown>): boolean {
return box instanceof Box<string>; // ошибка: instanceof не принимает аргументы типа
}
// нельзя перегрузиться по аргументу типа — перегрузка различает рантайм-формы, а T стёрт:
function read(box: Box<string>): string;
function read(box: Box<number>): number; // эти две сигнатуры в рантайме неразличимы
function read(box: Box<unknown>): unknown {
return box.value;
}
new T() невозможен — конструктора по имени T в рантайме нет. instanceof Box<string> невозможен дважды: instanceof вообще не принимает аргументы типа, а если бы и принимал, проверять <string> было бы не на чем — он стёрт. Перегрузка по аргументу типа бессмысленна — перегрузки различают значения по их рантайм-форме, а Box<string> и Box<number> в рантайме — один Box; компилятор сведёт обе сигнатуры к одной реализации и ничего не сможет выбрать по стёртому T.
Вывод про дженерики тот же, что и про всё остальное, только в чистом виде: параметр типа — это сообщение проверяющему и ничего больше. Он направляет вывод во время компиляции — связывает вход и выход, переносит форму через границу функции, ловит несовпадения. А к рантайму его нет, как нет и любого другого типа. Дженерик не «менее реальная» абстракция, чем интерфейс; он стирается по тому же закону.
Глазами джависта
Джависту здесь будет одновременно знакомо и обидно, и стоит развести две половины аккуратно.
Знакомо — стирание дженериков. В Java
List<String>иList<Integer>в рантайме — один и тот жеList; параметр типа выброшен. Поэтомуnew T()в Java тоже не пишут,obj instanceof List<String>запрещён, и перегрузиться поList<String>противList<Integer>нельзя — сигнатуры в рантайме совпадают. Всё, что эта глава говорит про дженерики TypeScript, джавист уже пережил у себя. Стирание ему не диковина; он живёт с ним годами.Обидно — что в TypeScript так стёрто всё, а не только дженерики. В Java негенериковые типы реальны в рантайме: JVM при загрузке класса верифицирует байт-код,
obj instanceof Order— обычное дело,obj.getClass()вернёт настоящийClass<?>, по которому можно рефлексировать поля и методы. Бирка пришита к объекту намертво, и рантайм её помнит. В TypeScript этой половины нет вовсе: послеtsc → .js → V8отOrderне остаётся ни класса, ни бирки, ни возможности спросить.Отсюда формула, которую полезно держать в голове до конца книги: TypeScript относится ко всем типам так, как Java — только к дженерикам. Универсальное стирание — не отдельный экзотический изъян TS, в который надо вникать с нуля. Это знакомый джависту принцип стирания дженериков, распространённый на каждый тип без исключения. Кусочек этого опыта у него уже есть; TypeScript всего лишь не делает исключений.
Чего нельзя сделать, и обходные пути
Теперь честный каталог невозможностей — не чтобы посетовать, а чтобы у каждой стены оказалась дверь. Закономерность одна: раз тип к рантайму стёрт, то всюду, где рантайм-поведение должно зависеть от типа, надо принести в рантайм значение, которое тип замещает. Стена — стёртый тип; дверь — реальное значение.
Нельзя инстанцировать параметр типа — new T() не существует. Дверь: передавай конструктор как обычное значение. Конструктор — настоящий объект, он переживает стирание.
function make<T>(ctor: new () => T): T {
return new ctor(); // ctor — рантайм-значение, его можно вызвать
}
class Cart {}
const cart = make(Cart); // тип cart выведен как Cart, в рантайме вызван new Cart()
Нельзя instanceof интерфейса — у interface Order нет рантайм-воплощения. Дверь: пользовательский type guard (проверка реальных полей) или, если форма размечена, поле-дискриминант.
type Event =
| { kind: "created"; id: OrderId }
| { kind: "paid"; id: OrderId; amount: number };
function handle(e: Event) {
if (e.kind === "paid") {
// kind — реальное поле, оно пережило стирание; проверяющий сузил e до варианта "paid"
console.log(e.amount);
}
}
Поле kind — обычная строка, лежащая в объекте; читать её в рантайме можно, и проверяющий по этой реальной проверке сужает тип. Это discriminated union — размеченное объединение, которому посвящена Глава 9; здесь важно лишь, что дискриминант работает потому, что он пережил стирание, а instanceof интерфейса — нет.
Нельзя интроспектировать тип — спросить у рантайма «какие поля у Order» некого. Дверь: схема. Библиотека вроде Zod описывает форму как рантайм-значение, из которого выводится статический тип, — и это значение остаётся в рантайме, чтобы проверять данные на границе (Глава 26).
import { z } from "zod";
const OrderSchema = z.object({ // это рантайм-значение, оно НЕ стирается
id: z.string(),
total: z.number(),
});
type Order = z.infer<typeof OrderSchema>; // тип выведен ИЗ значения-схемы
const parsed = OrderSchema.parse(JSON.parse(raw)); // реальная проверка формы в рантайме
Схема — это рантайм-тень типа: значение, которое несёт ту же форму и доживает до выполнения, поэтому ему можно скормить пришедший из сети объект и спросить, сходится ли форма. Тип выводится из схемы, а не наоборот; источник истины — рантайм-значение.
Каждая невозможность из этого каталога — указатель на будущую главу: конструктор-как-значение и фабрики (дальше, в части про дженерики), type guard’ы и дискриминанты (Главы 9 и 25), схемы на границе (Глава 26). Но указывают они в одну сторону. Стёртый тип не вернуть; вместо него в рантайм кладут значение, которое делает его работу.
Если нужен тип в рантайме — построй его сам
Вот сдвиг, ради которого написана глава.
Перестаньте ждать, что тип будет на месте во время выполнения. Знание, которым располагал проверяющий, к рантайму не доживает — не иногда, не в неудачных случаях, а никогда, по устройству языка. Привычка, принесённая из языков с честными рантайм-типами, нашёптывает обратное: «тип же есть, спрошу у значения, какой оно формы». Спросить не у кого. Бирки нет.
Из этого следует рабочее правило, и оно простое: если рантайм-поведение должно зависеть от типа, рантайм-представление этого типа придётся построить руками. Не понадеяться, что оно само там окажется, — а сознательно положить в исполняющую программу значение, которое тип замещает. Кандидатов ровно столько, сколько переживает стирание:
- поле-дискриминант — строка или число в объекте, по которому различают варианты;
- type guard — функция, делающая реальную проверку и сообщающая результат проверяющему;
- схема — рантайм-значение, несущее форму, из которого выводится тип;
- ссылка на конструктор — переданный как значение
class, который можно вызвать и проверить черезinstanceof.
Все четыре — это одно и то же действие в разных костюмах: взять знание, которое жило только в проверяющем, и материализовать его как настоящее значение, чтобы исполняющая программа могла на него опереться. Тип — статическое утверждение; чтобы оно что-то значило в рантайме, нужно его дублёр-значение, и положить дублёра — ваша работа.
И тогда стирание перестаёт быть стеной, об которую разбивается интуиция, и становится осью, вдоль которой выстраивается код. Граница ясна: до неё — статические утверждения, проверяемые и стираемые; за ней — значения, и только значения. Спрашивая себя «это решение зависит от типа в рантайме?», вы сразу знаете и ответ, и что делать: тип туда не дойдёт — принеси значение. Это не борьба с языком. Это работа в согласии с его единственным фундаментальным фактом.
Итог
Стирание — не дефект, который приходится терпеть, а ось, на которой держится язык: типы живут только во время проверки и не существуют нигде больше, а половина «странностей» TypeScript — это один и тот же факт в разных костюмах. Нужен тип в рантайме — построй его сам из того, что стирание пережило.