Главная / Статьи / Что на самом деле делает и чего не делает нативная поддержка TypeScript в Node.js

Что на самом деле делает и чего не делает нативная поддержка TypeScript в Node.js

В этой статье объясняется, как Node.js выполняет файлы .ts нативно с помощью удаления типов, почему он пропускает проверку типов и когда всё же требуется настоящий этап сборки.

1595 слов

Вы уже знаете, как это делается. Вы создаете новый проект на TypeScript, пишете первый файл с расширением .ts, пытаетесь его запустить, и сразу вспоминаете, что сначала необходимо выполнить целый ритуал настройки: установить ts-node или tsx, настроить файл tsconfig.json, возможно, настроить скрипт сборки и определиться, будете ли вы использовать формат CommonJS или ESM. Ни один из этих шагов сам по себе не является особенно сложным. Это просто лишние трудности, которые накапливаются до того, как вы напишете хоть какую-то логику приложения, и это происходит каждый раз, когда вы начинаете что-то новое.

В этом году в значительном числе реальных проектов на Node.js вся эта процедура просто исчезла. Достаточно лишь ввести команду node file.ts — и всё заработает. Никаких флагов, никаких дополнительных зависимостей, никаких файлов конфигурации. Это изменение внедрилось тихо, без какого-либо масштабного объявления, но это именно тот вид устранения мелких препятствий, с которыми обычно приходится сталкиваться многократно в течение недели — и все эти мелочи в сумме делают что-то действительно стоящее для изучения.

Что на самом деле происходит

Основной механизм называется удалением типов, и название это буквально точно отражает суть: Node.js парсит исходный код на TypeScript, удаляет аннотации типов и запускает оставшийся обычный JavaScript. В этом вся суть.

// Before: what you write
interface User {
  name: string;
  age: number;
}
function describeUser(user: User): string {
  return `${user.name} is ${user.age} years old`;
}
// After: what Node.js actually executes, post-stripping
// (whitespace preserved, so line numbers stay accurate for debugging)
function describeUser(user) {
  return `${user.name} is ${user.age} years old`;
}

Декларация interface полностью исчезает. Аннотации вроде : User и : string удаляются. Остаётся обычный, корректный JavaScript, который V8 выполняет так же, как и всегда — не требуется никакой специальной среды выполнения, нет полифилов, и в момент выполнения концептуально ничего нового не происходит.

На самом деле этот процесс выполняется с помощью библиотеки под названием Amaro, которая представляет собой легковесный оберток вокруг @swc/wasm-typescript — компилятора WebAssembly для парсера TypeScript, созданного SWC на языке Rust. Высокая скорость достигается не за счет каких-то сложных оптимизаций, а благодаря тому, что выполняется гораздо меньше работы по сравнению с полноценным компилятором. Она не решает проблемы типов в нескольких файлах, не проверяет корректность аннотаций и не генерирует файлов объявлений. Она просто анализирует дерево синтаксиса, удаляет элементы, характерные исключительно для TypeScript, и возвращает JavaScript. Именно такой узкий диапазон задач и делает ее быстрой.

Поддержка этой функции в Node проходила несколько этапов до достижения своего нынешнего вида: экспериментальная поддержка простого удаления типов появилась в версии v22.6.0, отдельный флаг для обработки более сложных конструкций, таких как enum, появился в v22.7.0, а вся функция стала по умолчанию стабильной как в v22.18.0, так и в v24.3.0 — это означает, что для кода, соответствующего поддерживаемой синтаксису, вовсе не требуются никакие флаги. Примечательно, что позже Node полностью устранил этот флаг, предназначенный специально для enum, выбрав вместо этого сознательно узкий и предсказуемый диапазон функциональности вместо попыток поддержать весь язык.

Честные ограничения

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

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

Более значимое ограничение касается того, какая именно синтаксис TypeScript может быть удалена. Node поддерживает только так называемую удаляемую синтаксис — конструкции языка, которые можно полностью устранить без изменения поведения кода при его выполнении. Значительная часть TypeScript не соответствует этому критерию, поскольку она обеспечивает реальное поведение во время выполнения, которое нельзя просто удалить:

// ❌ Fails under type stripping — enums generate a real runtime object
enum Direction {
  Up,
  Down,
  Left,
  Right,
}
// ❌ Fails - parameter properties generate constructor assignment code
class Point {
  constructor(public x: number, public y: number) {}
}
// ❌ Fails - this is a CommonJS-style module alias, not an erasable type
import fs = require('fs');
// ❌ Fails - angle-bracket type assertions look like real syntax to strip,
// but the parser can't tell it apart from JSX safely
const num = <number>someValue;

Каждая из этих конструкций приводит к фатальной ошибке вместо того, чтобы скрыто сгенерировать некорректный результат — механизм удаления элементов в Node специально разработан так, чтобы останавливаться и сообщать об ошибке, вместо того чтобы догадываться о намерениях пользователя. Декораторы в стиле устаревших версий, активируемые с помощью старого флага experimentalDecorators, сталкиваются с той же проблемой по тем же причинам. Более новые декораторы, соответствующие стандартам TC39, — это другая история: они определены таким образом, что компилируются в обычную синтаксис JavaScript, поэтому нет ничего особенного, что можно было бы удалить, и они работают в Node без каких-либо проблем.

Как адаптировался TypeScript

Вместо того чтобы разработчики постепенно находили эти ограничения, пробуя код по одному файлу, команда TypeScript быстро сделала правила более явными. В версии TypeScript 5.8 появился новый флаг компилятора --erasableSyntaxOnly, который заставляет сам tsc отклонять любые из вышеописанных неразрешаемых синтаксических структур во время компиляции. Это превращает вопрос «сможет ли Node на самом деле запустить этот код» из чего-то, что вы узнаете только на этапе выполнения, в правило, которое можно сразу же применить в качестве четкого ограничения для всего кодового базиса.

// tsconfig.json
{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true  // pairs well with this —
                                   // keeps type-only imports explicit
  }
}

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

Объем работы по миграции сильно варьируется в зависимости от исходной точки. Новый бэкенд-сервис или инструмент командной строки обычно могут сразу включить режим erasableSyntaxOnly, при этом требуется минимальная или вовсе никакая доработка. Однако кодбаза, в которой активно используются декларации enum, или та, что построена на фреймворке, предполагающем использование устаревших декораторов — такие как старые версии NestJS или TypeORM — требует серьезной переработки, либо вынуждает выбирать традиционный процесс сборки вместо миграции всего за один раз. Самый надежный способ оценить объем работы перед принятием решений — это включить erasableSyntaxOnly, запустить команду tsc --noEmit один раз и посмотреть, сколько ошибок появится. Этот один запуск позволит определить реальный масштаб проблемы еще до изменения каких-либо настроек во время выполнения программы.

Практические рекомендации

В командах, которые уже прошли этот процесс перехода, сформировалась довольно единая система принятия решений.

Не включайте шаг сборки для бэкенд-сервисов, инструментов командной строки, внутренних утилит и автономных скриптов — то есть для всего, что запускается непосредственно под Node и не публикуется в виде пакета для использования другими. Именно для таких случаев и была создана функция удаления компонентов, причем сообщается, что сервисы, построенные на Express или Fastify, обычно начинают работать с ней сразу без необходимости изменения кода.

Обязательно предусмотрите шаг сборки для всего, что выполняется в браузере, поскольку браузеры вообще не могут запускать файлы .ts — вам всё равно понадобится инструмент для сборки независимо от того, что поддерживается Node на сервере. Также установите шаг сборки для любых пакетов npm, которые вы публикуете, ведь те, кто их устанавливает, нуждаются в скомпилированном JavaScript и файлах объявлений .d.ts, причём нет никаких гарантий, что версия Node у них поддерживает удаление типов. Кроме того, необходим шаг сборки для любых кодовых баз, которые всё ещё используют устаревшие декораторы или обильное использование enum и ещё не были преобразованы.

Каким бы путём вы ни пошли, продолжайте запускать команду tsc --noEmit в CI. Удаление шага сборки удаляет лишь этап компиляции — он никогда не предназначался для отмены проверки типов, и рассматривать его как замену tsc — вот единственный способ, при котором такое изменение может незаметно поставить под угрозу безопасность.

Основной вывод

Интерес этого сдвига заключается не столько в ускорении, хотя более быстрый цикл обратной связи действительно приносит немедленную пользу, сколько в сигнале о том, куда концептуально движется TypeScript. Большую часть своей истории TypeScript описывался как язык, компилируемый в JavaScript — отдельный язык, который переводится перед выполнением. Процесс удаления типов в Node намекает на то, что TypeScript постепенно становится более похожим на вариант JavaScript, который среда выполнения может читать без дополнительной обработки, по крайней мере для той широкой повседневной части языка, которую на самом деле используют большинство разработчиков. Это не весь язык, и таким он никогда не будет — у перечислений и старых декораторов по-прежнему есть реальные сценарии применения, и они не исчезнут. Но для всего кода, который ими не нуждается, этот шаг, ранее находившийся между написанием кода на TypeScript и его выполнением, больше не является обязательным.

Это гораздо более значительное изменение, чем может показаться судя по скромному вниманию, которое ему уделили.

Связанные статьи