← Глава 2. Структурная истина против номинального ярлыка Содержание Глава 4. Вывод вместо объявления →

Глава 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));

Сверим построчно. Исчезли:

Не исчезло — потому что это настоящий 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). Но указывают они в одну сторону. Стёртый тип не вернуть; вместо него в рантайм кладут значение, которое делает его работу.

Если нужен тип в рантайме — построй его сам

Вот сдвиг, ради которого написана глава.

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

Из этого следует рабочее правило, и оно простое: если рантайм-поведение должно зависеть от типа, рантайм-представление этого типа придётся построить руками. Не понадеяться, что оно само там окажется, — а сознательно положить в исполняющую программу значение, которое тип замещает. Кандидатов ровно столько, сколько переживает стирание:

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

И тогда стирание перестаёт быть стеной, об которую разбивается интуиция, и становится осью, вдоль которой выстраивается код. Граница ясна: до неё — статические утверждения, проверяемые и стираемые; за ней — значения, и только значения. Спрашивая себя «это решение зависит от типа в рантайме?», вы сразу знаете и ответ, и что делать: тип туда не дойдёт — принеси значение. Это не борьба с языком. Это работа в согласии с его единственным фундаментальным фактом.

Итог

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

← Глава 2. Структурная истина против номинального ярлыка Содержание Глава 4. Вывод вместо объявления →