Синтаксис, который нельзя просто стереть

классы≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Правило «типы стираются» работает ровно до встречи с enum. У TypeScript есть четыре конструкции, которые вычеркнуть нельзя: после них в выполненном файле остаётся работающий код, которого в исходнике не было. Из-за них node app.ts иногда отказывается открывать файл — а флаг erasableSyntaxOnly предупреждает об этом заранее.

Разделы темы:·

В статье про три способа запустить .ts осталось незакрытое обещание — полный список того, что стереть нельзя. Начинается он с простого признака: объявление либо порождает значение, либо описывает только тип. Тип живёт до сборки и исчезает бесследно. Значение обязано доехать до выполнения, а значит, кто-то должен написать его в выходном файле.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Point и Meters из выполненного кода исчезли — на их месте нет ни undefined, ни пустого объекта. Circle остался, но это не заслуга TypeScript: класс есть и в обычном JavaScript, компилятору достаточно снять с него аннотации. А вот Unit — случай особый. Конструкции enum в JavaScript нет вовсе, и объект под этим именем компилятор сочинил сам.

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

Стирающий инструмент ваши типы даже не пытается понять. Отсюда и скорость, и полное отсутствие проверок.

Конструкций, которые не переживают стирание, всего четыре: enum (вместе с const enum), namespace с исполняемым содержимым, параметры-свойства в конструкторе и пара import ... = require(...) / export =. У всех четырёх общая биография: они появились тогда, когда у JavaScript ещё не было ни модулей, ни полей класса, и TypeScript закрывал дыры своими средствами.

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

Node на файл с enum отвечает отказом: SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode. Отказ приходит на разборе, а не на выполнении: если enum объявлен во второй строке, то console.log из первой ничего не напечатает. Файл не запускается целиком.

Интереснее другое: у enum есть вариант, который стирается прекрасно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и ReferenceError на запуске. Слово declare означает «объект уже существует, создавать его не надо», и компилятор послушно ничего не создаёт: в выходном файле остаётся голое Http.Ok. Кто предоставит сам объект — ваша забота; обычное место такой записи — .d.ts к чужой библиотеке, где объект действительно есть, просто написан не вами.

Отсюда и правило: erasableSyntaxOnly пропускает declare enum, потому что порождать нечего. Флаг смотрит не на ключевое слово, а на то, появится ли в выходном файле код.

Замысел const enum ровно такой: никакого объекта, компилятор подставляет число прямо в место использования. Но подстановка доступна только тому, кто видит объявление и знает значения, то есть построил программу целиком.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Однофайловые инструменты так не умеют — ни встроенное стирание в Node, ни esbuild, ни песочница учебника, которая компилирует ваш пример в одиночку: здесь объект всё-таки создаётся. Node в тонкости не вдаётся и выдаёт то же сообщение про enum, а erasableSyntaxOnly запрещает const enum наравне с обычным.

До ES-модулей файлы просто склеивали в один, и всё объявленное оказывалось в глобальной области. namespace был штатным лекарством: он собирал свои имена в один объект, чтобы они не сталкивались с чужими.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор разворачивает namespace в самовызывающуюся функцию и объект, куда складываются экспортированные имена. Приём знакомый: ровно так до модулей писали «модуль на замыкании» — внутри обёртки приватное, наружу торчит только то, что положили в объект. Сегодня для этого есть import и export, а namespace остался в наследство.

Но не всякий namespace одинаков. Если внутри одни типы, порождать нечего — и порождать не будут.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор возражает на typeof Api диагностикой TS2708: «Невозможно использовать пространство имен "Api" как значение». А в выводе — undefined, потому что после стирания имени Api в файле нет вообще, а typeof на необъявленном имени в JavaScript не бросает исключение. Такой namespace и Node запустит, и erasableSyntaxOnly пропустит: решает не ключевое слово, а наличие значений внутри.

Старое написание module Legacy { ... } в TypeScript 6 стало ошибкой TS1540 — компилятор просит писать namespace.

Импорт CommonJS-модуля до ESM писали как import fs = require("node:fs"), а экспорт единственного значения — как export = Cart. Обе строки превращаются в настоящий код: вызов require и присваивание в module.exports. Node отвечает на первую TypeScript import equals declaration is not supported in strip-only mode.

Замена — обычные import и export из главы про модули. Если пакет старый и отдаёт единственное значение через module.exports, эту разницу берёт на себя настройка esModuleInterop, и в коде остаётся привычное import fs from "node:fs".

Самая коварная из четырёх конструкций, потому что маскируется под аннотацию. private, public и readonly у обычного поля — чистая разметка для компилятора, она стирается вместе с типами. Но стоит поставить такой модификатор перед параметром конструктора — и смысл меняется полностью.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В теле класса не объявлено ни одного поля, а в экземпляре их два. Компилятор дописал и объявления, и присваивания this.label = label в начало конструктора — вместо вас. Заодно видно, что private на выполнение не влияет никак: поле спокойно попадает в Object.keys, приватность здесь живёт только в типах, о чём была отдельная статья главы.

Ручной эквивалент занимает вчетверо больше места — именно поэтому параметры-свойства когда-то и придумали.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разница между двумя версиями — только в объёме текста: после сборки они почти совпадают. Но первую Node не откроет, а вторую откроет, и сообщение указывает прямо на модификатор в скобках: TypeScript parameter property is not supported in strip-only mode.

Модификатор перед параметром — единственное место в языке, где приписка к типу порождает присваивание. Ловушка ровно в этом.

Держать список в голове необязательно. В tsconfig.json есть "erasableSyntaxOnly": true: с ним каждая из четырёх конструкций становится ошибкой TS1294 — «Этот синтаксис недопустим, если включен параметр "erasableSyntaxOnly"». Ошибка приходит на проверке типов, то есть до того, как кто-нибудь попробует запустить файл.

Флаг появился в TypeScript 5.8 — тогда же, когда Node научился открывать .ts сам. Смысл в том, чтобы договориться заранее: если проект собирается стиранием, пусть о запретном синтаксисе скажет компилятор, а не упавший сервер.

КонструкцияЧто порождаетerasableSyntaxOnly
enum Status { ... }объект с прямым и обратным отображениемTS1294
const enum Status { ... }подстановку значений, а в однофайловой сборке — тот же объектTS1294
declare enum Status { ... }ничего: описание чужого объектапропускает
namespace N { export const x = 1 }объект и функцию-обёрткуTS1294
namespace N { export type X = string }ничего: внутри одни типыпропускает
constructor(private x: number)поле и присваивание в конструктореTS1294
import fs = require("node:fs")вызов requireTS1294
export = Cartприсваивание в module.exportsTS1294
<string>raw вместо raw as stringничего: в выходном файле остаётся одно rawTS1294
abstract, implements, declare у поляничегопропускает

Стоит понимать, чего флаг не делает. Он не смотрит, чем вы запускаете код, и ничего не знает про версию Node: это чисто синтаксическая проверка. И он не запрещает ни абстрактные классы, ни implements, ни declare, ни настоящую приватность через # — всё это либо стирается, либо давно есть в самом JavaScript.

У правила «флаг смотрит на выходной файл» есть одно исключение, и оно в таблице выше. Утверждение типа угловыми скобками — <string>raw вместо raw as string — не порождает ничего: в скомпилированном файле от него остаётся одно raw. Флаг всё равно отвечает TS1294, и причина в соседнем расширении: в .tsx та же запись разбирается как JSX, поэтому стирающему инструменту пришлось бы разбираться ещё и в том, что это за файл. Записи через as это не касается.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В этом файле не осталось ни одной строки, которую нельзя вычеркнуть: объект с as const — обычный JavaScript, поля класса объявлены явно, тип собран из литералов. Такой файл откроет и node app.ts, и любой сборщик, и tsc.

Несколько лет инструменты сходились к одному решению: esbuild, SWC, Vite, Bun, Deno, а с версии 22.18 и сам Node — все они типы стирают и не проверяют. Причина в скорости: стирание — один проход по файлу, а проверка требует построить программу целиком.

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

  • Синтаксис TypeScript делится по признаку «порождает значение»: типы вычёркиваются пробелами, а порождающие конструкции пришлось бы сгенерировать — и стирание этого не умеет.
  • Не стираются четыре вещи: enum и const enum, namespace с исполняемым содержимым, параметры-свойства в конструкторе и import ... = require(...) с export =.
  • enum оставляет объект с обратным отображением, namespace — объект и функцию-обёртку, параметр-свойство — объявление поля и присваивание.
  • namespace из одних типов, declare enum, abstract, implements и declare у поля стираются целиком и остаются разрешёнными.
  • "erasableSyntaxOnly": true превращает запретный синтаксис в ошибку TS1294 на проверке — вместо отказа Node при запуске, который приходит на разборе и не даёт выполниться ни одной строке.

1/4В файле app.ts первая строка — console.log("старт"), а вторая объявляет enum. Что напечатает node app.ts?

2/4Два пространства имён: в первом лежит export const version = 1, во втором — только export type Id = string. Что скажет про них компилятор с erasableSyntaxOnly?

3/4Почему constructor(private name: string) нельзя стереть, ведь private — пометка уровня типов?

4/4const enum задуман так, чтобы объекта в собранном коде не осталось. Пропустит ли его erasableSyntaxOnly?

Файл, который Node не откроет

Пример ниже компилятор пропускает, а node cart.ts отказывается открывать — причём мешают сразу две конструкции. Найдите обе и перепишите файл так, чтобы поведение и вывод сохранились, а стирание типов прошло без отказа.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Мешают namespace с исполняемым содержимым и параметр-свойство в конструкторе Item. Первый заменяется обычными объявлениями: имена в файле и так не текут наружу, пока их не экспортировали, — заворачивать их в объект незачем. Второй — явными полями и присваиваниями.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод тот же — 800. Проверить себя можно, не запуская Node: включите erasableSyntaxOnly, и компилятор поставит TS1294 ровно на те строки, которые мешали.

Два namespace ради одного имени

Форматирование и разбор сумм разложили по двум пространствам имён — иначе две функции money столкнулись бы. Перепишите файл без namespace, сохранив и вызовы, и вывод: Format.money и Parse.money должны работать по-прежнему.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор и так разворачивает namespace в объект — значит, объект можно написать самому, и он ничем не хуже.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вызовы не изменились ни в одном символе, а нестираемого синтаксиса в файле не осталось. В настоящем проекте развилка обычно ещё проще: две функции живут в двух файлах, и от столкновения имён спасает import * as Format from "./format.js" — группировка по модулям, ради которой namespace когда-то и был нужен.

Что переживёт стирание

Перед запуском выпишите четыре ответа: что напечатает typeof для Level, Config, Labels и Ticket. Заодно предскажите, будет ли диагностика, и если да — на какой строке.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вывод — object object undefined function. Level и Config порождают объекты: первый — enum, второй — namespace со значением внутри. Ticket — обычный класс, а классы в JavaScript функции. А Labels состоит из одних типов, поэтому в выходном файле такого имени просто нет.

Диагностика одна, на последней строке: TS2708, «Невозможно использовать пространство имен "Labels" как значение». Компилятор возражает — и всё равно выдаёт код, где typeof честно печатает undefined: на необъявленном имени typeof в JavaScript не бросает исключение.

Из четырёх имён erasableSyntaxOnly запретил бы два — Level и Config. Labels и Ticket стираются или существуют в JavaScript сами по себе.