Встроенная поддержка TypeScript в Node: 7 реальных проблем и способы их решения
Узнайте, какие функции TypeScript незаметно нарушаются внутренним средством удаления типов Node в производственной среде, а также какие именно настройки помогают решить проблемы, проверенные в Node 22.18+ и 24.x LTS.
Команда, мигрировавшая небольшой сервис на Express, решила отказаться от ts-node и запускать приложение напрямую с помощью команды node file.ts в среде стейджинга. На локальных тестах такой подход казался безупречным, но уже в следующий рабочий день в CI-пайплайне начались сбои при сборке, некоторые разработчики сталкивались в своих терминалах с ошибкой ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, а в продакшен был выпущен временный фикс без какой-либо проверки типов, поскольку этот защитный механизм незаметно исчез.
Встроенная поддержка TypeScript в Node через удаление типов — это надежная функция. Однако она покрывает лишь узкий диапазон возможностей TypeScript, чем привыкли считать многие, и эти ограничения становятся очевидными только при практическом использовании. Ниже приведены семь проблем, возникших во время реальной миграции, а также конкретные ошибки и способы их устранения, проверенные на Node 22.18+ и 24.x LTS.
Обещания против реальности
Node выполняет TypeScript путем удаления аннотаций типов во время выполнения с использованием встроенной копии swc. Он никогда не обращается к компилятору TypeScript. Не происходит никакой фазы tsc. В результате отсутствует проверка типов, преобразование синтаксиса для старых версий, разрешение псевдонимов путей, поддержка .tsx, поддержка декораторов, энумерации, а также генерация кода во время выполнения для пространств имён.
Запуск команды node file.ts действительно работает и представляет собой реальную возможность, но это минимальный набор функций, а не полный набор возможностей TypeScript.
1. Мои относительные импорты молча возвращали ошибку 404 в продакшене
Симптомы. Всё работало локально. Но после развертывания приложение не запускалось с ошибкой вроде этой:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js
Что произошло. Функция удаления типов удаляет только аннотации; все остальные токены в файле остаются нетронутыми, включая пути импорта. Поэтому файл исходного кода вот такого вида:
// src/server.ts
import { hashToken } from "./utils/hash.js";
проходит без изменений. Во время локальной разработки команда node --experimental-strip-types была достаточно умной, чтобы найти файл ./utils/hash.ts, несмотря на то, что в его расширении указано .js. Однако процесс сборки (компиляция с помощью tsc в папку dist) оставил в строке исходное расширение .js, и в папке dist/utils/ не оказалось соответствующего файла с расширением .js — там были скомпилированы только исходники с расширением .ts.
Решение. Необходимо внести два отдельных изменения одновременно.
Во-первых, укажите расширение импорта, соответствующее тому, что фактически находится на диске — а именно .ts, а не .js:
// src/server.ts
import { hashToken } from "./utils/hash.ts";
Во-вторых, сообщите компилятору, что это сделано намеренно, и позвольте ему изменить расширение во время генерации кода:
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}
Ключевое настройка — rewriteRelativeImportExtensions: true, которая преобразует ./utils/hash.ts обратно в ./utils/hash.js при генерации вывода тулзой tsc, чтобы скомпилированный JavaScript продолжал корректно работать для всех программ, которые им пользуются. Параметр noEmit: true здесь обязателен — без него параметр allowImportingTsExtensions вызывает ошибку TS5096. Подробнее см. в документации TypeScript.
2. Половина моего кодового базиса имела «неподдерживаемую синтаксис»
Симптом. Один из разработчиков столкнулся с этой ошибкой при своем первом коммите после переключения:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum declarations are
not supported by Node's type stripping. Convert enums to objects with `as const`
or use a transformer.
Другой столкнулся с похожей проблемой при использовании конструктора класса:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.
Что произошло. Механизм удаления типов в Node намеренно поддерживает только ограниченный набор синтаксиса: конструкции, которые можно полностью удалить без изменения поведения во время выполнения, известные как синтаксис «erasable». Любая функция TypeScript, которая фактически генерирует логику на языке JavaScript во время выполнения, отклоняется с исключением во время выполнения вместо того, чтобы быть преобразована.
Полный список неподдерживаемых конструкций, взятый из официальной документации Node TypeScript, включает: объявления enum, которые должны преобразовываться в союз строк или объекты с использованием as const; блоки namespace, содержащие логику во время выполнения, для которых необходимо переместить экспортируемые значения в обычные экспорты модуля (названия типов в namespace остаются без изменений); свойства параметров в конструкторах (например, constructor(public x: number)), вместо которых требуется явное объявление поля; псевдонимы импортов, которые должны быть переименованы в момент импорта; декораторы, которые не работают на уровне парсера и не подвергаются полифилингу; а также файлы с расширением .tsx, поскольку признаются только расширения .ts, .mts и .cts.
Решение. Вот как решается вопрос с enum case:
// before ; dies at runtime
enum Role { Admin = "admin", User = "user" }
// after ; works under type stripping AND in tsc
const Role = {
Admin: "admin",
User: "user",
} as const;
type Role = (typeof Role)[keyof typeof Role];
Для декораторов наиболее безопасным подходом является ожидание того момента, когда парсер Node будет нативно поддерживать предложение TC39 по декораторам, прежде чем их внедрять, либо использование трансформаторов вроде swc или tsc в работе с файлами, которые от них зависят. Также имеет смысл включить параметр "erasableSyntaxOnly": true в файл tsconfig.json — это позволяет компилятору отмечать неподдерживаемую синтаксис прямо в редакторе ещё до того, как код попадет во время выполнения.
3. Проверка типов тихо не происходит
Симптом. Обработчик данных принял значение null, где ожидался тип string, и вышел из строя при вызове метода .length для этого значения. Юнит-тест для соответствующего маршрута пройдён без ошибок. Переменная была объявлена как string, но фактическое значение было null, при этом файл node file.ts выполнился без каких-либо проблем.
app.post("/webhook", (req, res) => {
const body: string = req.body.payload; // null sneaks in, no one notices
console.log(body.length);
});
Что произошло. Отбор типов осуществляется исключительно на текстовом уровне — он никогда не обращается к проверщику типов. Ничто в процессе выполнения через node не проверяет, соответствуют ли значения, проходящие через код, заявленным типам.
Это, пожалуй, самый рискованный способ скрытого сбоя, возникающий при отказе от использования ts-node. Успешное выполнение файла node file.ts ничего не говорит о том, является ли код типобезошибочным.
Решение. Вновь ввести проверку типов как отдельный, явно определенный шаг.
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"ci": "npm run typecheck && npm run lint"
}
}
Запускайте tsc --noEmit в рамках процесса CI для каждого pull request, а также рассмотрите возможность интеграции этой процедуры в хуки pre-commit, если это соответствует вашей рабочей схеме. Нативная среда выполнения запускает код; tsc — это инструмент, отвечающий за выявление ошибок типов. Эти две функции теперь полностью разделены, и такое разделение является частью концепции проектирования Node.
Также стоит включить "erasableSyntaxOnly": true вместе с "verbatimModuleSyntax": true в файле tsconfig.json. Первое настройка заставляет тулзу tsc отклонять всё, с чем нельзя справиться при удалении типов — таким образом декораторы и перечисления обнаруживаются на этапе компиляции, а не во время выполнения. Вторая настройка требует использования явных операторов import type, чтобы импорты, содержащие только типы, не оставляли после себя ненужных операторов импорта во время выполнения.
4. Мои пути-псевдонимы @/utils/* перестали работать
Симптомы. Знакомая ошибка:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts
Что происходило. Поле paths в файле tsconfig.json представляет собой исключительно удобство компиляции для TypeScript — сам Node никогда им не пользовался. Инструменты вроде ts-node и tsx учитывали его, поскольку реализовывали собственную логику разрешения модулей поверх Node. Функция удаления нативных типов этого не делает; она передаёт задачу разрешения напрямую загрузчику Node.
// tsconfig.json ; this never worked at runtime, it only worked in your editor
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/utils/*": ["src/utils/*"] }
}
}
Решение. Существует три разумных варианта действий в зависимости от способа развертывания вашего приложения.
Вариант А: использовать встроенные импорты подпутей в Node. Полностью удалите поля paths из tsconfig и вместо этого укажите соответствия в файле package.json:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
// src/server.ts
import { logger } from "#utils/logger.ts";
Это корректно работает в среде node, при использовании tsc --noEmit и в vitest, без необходимости какой-либо дополнительной настройки. Префикс # — это собственная конвенция Node, указывающая на то, что речь идет об внутреннем псевдониме, а не о публикуемом пакете. Для миграции достаточно выполнить поиск и замену во всем проекте с @/utils/ на #utils/.
Вариант B: использовать относительные импорты и мириться с цепочками ../. Это трудоемко, но не требует использования каких-либо скрытых инструментов.
Вариант C: использование преобразователя для перезаписи путей. Инструменты вроде tsc-alias переписывают сгенерированный JavaScript после компиляции, или можно позволить tsx/swc разрешать псевдонимы во время выполнения. Это вновь вводит этап сборки, который вы пытались устранить, что снижает преимущества нативного TypeScript. Этот путь не стоит выбирать к 2026 году.
5. Взаимодействие CommonJS и ESM застало меня врасплох
Симптом. Вызов require("openai"), который раньше всегда работал, вдруг начал выдавать ошибку:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.
Или, в обратном направлении, импорт из папки без расширения приводил к сбоям:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM
Что происходило. Ранее ваш исходный файл компилировался с помощью tsc в файл формата .js в папке dist/, и функция require() работала как ожидается. При нативной обработке TypeScript файл, который фактически загружается Node, — это сам файл формата .ts, и Node определяет, следует ли рассматривать его как CommonJS или ESM, исходя из значения поля "type" в ближайшем файле package.json. Если это поле содержит значение "module", все файлы формата .ts в данной области видимости считаются ESM, поэтому любые оставшиеся вызовы require() приводят к сбоям. Если это поле отсутствует (что по умолчанию означает CommonJS), возникает противоположная проблема: импорт зависимостей, совместимых только с ESM, не удается выполнить.
Решение. Выберите одну систему модулей и обеспечьте её соблюдение во всем проекте.
Если вы только начинаете, укажите "type": "module" в файле package.json, пишите всё в формате ESM, а расширения .mts/.cts оставьте для тех редких файлов, которым действительно необходим другой формат.
// package.json
{
"type": "module",
"engines": { "node": ">=22.18.0" }
}
// src/server.ts
import { readFile } from "node:fs/promises"; // ESM, native
import OpenAI from "openai"; // pure ESM upstream
const openai = new OpenAI();
Для существующей базы кода на CommonJS сохраните значение "type": "commonjs" (или вообще не указывайте это поле) — и избегайте попыток подключить чистый ESM-пакет из кода на CommonJS без использования динамического вызова import(). Node 22.12+ действительно поддерживает стабильную функцию require(esm), но использование её всё равно создаёт риски, связанные с двойным использованием пакетов, и делает процесс сборки хрупким. Более безопасными вариантами являются преобразование вызывающего файла в формат ESM или обёртка зависимости с помощью динамического импорта внутри функции async.
Здесь есть ещё одна ловушка: импорты из каталогов. В режиме ESM запись import x from "./folder" не приведёт автоматически к ./folder/index.ts, как это могло быть раньше. Необходимо явно указать имя файла:
// bad
import { routes } from "./routes";
// good
import { routes } from "./routes/index.ts";
6. Режим наблюдения и горячая замена сделали шаг назад
Симптомы. После перехода с tsx watch src/server.ts на node --watch src/server.ts несколько аспектов ухудшились:
- Скорость перезапуска —
node --watchработает, но значительно медленнее. - Надёжная замена содержимого при изменениях в импортируемом файле, находящемся не в корне проекта.
- Возможность инициировать ручной перезапуск с помощью
SIGUSR2. - Цветовой вывод и дружелюбное сообщение «нажмите R для перезапуска».
node_modules, dist и .test.ts.Что происходило. node --watch — это давно существующий встроенный монитор файлов в Node. Теперь он работает с файлами .ts благодаря удалению типов, но изначально он не предназначался для замены специализированных инструментов вроде tsx watch или nodemon; скорее, это базовая функция.
Решение. Используйте node --watch, когда вам нужна просто полная перезагрузка при любых изменениях в отдельном скрипте. Для настоящего сервера с цепочкой импортов и реального цикла разработки или тестирования оставайтесь с tsx watch. Нет ничего плохого в том, чтобы продолжать использовать tsx в качестве инструмента разработки даже после перехода на выполнение кода в нативном TypeScript.
// package.json ; pragmatic split
{
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"start:native": "node src/server.ts"
}
}
tsx работает на esbuild и значительно быстрее, чем tsc — согласно собственным тестам проекта, примерно в 20–30 раз быстрее — он изначально поддерживает псевдонимы путей, и его поведение аналогично node --watch, если бы режим наблюдения получил больше внимания со стороны разработчиков.
7. Пропуск этапа сборки лишь переместил проблему
Симптомы. После объявления о том, что команда откажется от tsc в пользу нативной обработки кода, почти сразу возникло несколько проблем:
- SDK, опубликованный в npm, требовал файлы объявлений
.d.tsдля последующих пользователей; нативная обработка TypeScript их не генерирует. - Цель развертывания в Lambda ожидала вывод в формате CommonJS, но код был написан в формате ESM.
.ts, а не скомпилированный результат.Что произошло. Оптимизация кода происходит во время выполнения, а не при сборке — именно в этом заключается смысл данной функции. Но как только ваш код должен запускаться не в среде «Node 22 или новее, выполняющей код непосредственно из репозитория», вам снова требуется этап компиляции. Он не исчез; он просто переместился в другую часть процесса обработки.
Решение. Четко определите, какой именно контент вы фактически передаете.
Если вы разрабатываете приложение — сервис, который вы сами развертываете и запускаете, — использование нативного TypeScript действительно улучшает качество результата. Не требуется процесс сборки, время запуска меньше, а файл Dockerfile становится проще, поскольку достаточно просто выполнить команду COPY src ./src вместо управления папкой dist/.
# Dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY tsconfig.json ./
CMD ["node", "--enable-source-maps", "src/server.ts"]
Если вы поддерживаете библиотеку, предназначенную для npm, сохраните инструмент tsc для фактического шага генерации кода. Можете свободно использовать нативный TypeScript во время разработки и тестирования, но опубликованный пакет всё равно должен содержать скомпилированные файлы .js вместе с файлами .d.ts.
// package.json ; library case
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"test": "node --test --experimental-strip-types test/*.test.ts"
}
}
Если ваша цель — безсерверные решения, edge-рентаймы или приложения на Node 20.x, вам всё равно нужен транспилятор — либо swc, либо tsc — настроенный так, чтобы генерировать код, который смогут выполнить более старые рентаймы. Шаг сборки, который, как вам казалось, можно было опустить, там всё ещё необходим.
Стоило ли этого? Честное мнение.
Нативная поддержка TypeScript может стать самым значительным улучшением для Node с появлением async/await — но к этому комплименту прилагается серьёзное ограничение.
Имеет смысл внедрять её, если:
- Вы развертываете самоуправляемый сервис на Node 22.18+ или в линейке 24.x LTS.
- Вы уже пишете чистый, удобный для обработки TypeScript-код — без энумераций, без декораторов, исключительно с использованием стандартной синтаксиса ESM.
- У вас есть задача CI, выполняющая команду
tsc --noEmit, чтобы проверка типов не исчезала незаметно из вашего рабочего процесса.
node_modules.Лучше оставаться при использовании tsx или tsc, если:
- Вы публикуете библиотеку, которая должна работать на старых версиях Node для ваших пользователей.
- Вы сильно зависите от NestJS, TypeORM, class-validator или других инструментов, основанных на экспериментальных декораторах.
- Вам нужна поддержка формата
.tsxдля компонентов React, генерируемых на сервере. - Вы используете псевдонимы путей в
tsconfigи ещё не готовы перейти на полеimports. - У вас пока нет дисциплины (или инструментов) для исключения из кодбазы синтаксиса, который нельзя удалить.
Настройки, которые в итоге сделали это работоспособным:
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
// package.json (snippet)
{
"type": "module",
"engines": { "node": ">=22.18.0" },
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --enable-source-maps src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
"ci": "npm run typecheck && npm test"
}
}
Вот вся конфигурация. Ни следа ts-node. Нет и tsc в пути выполнения приложения. Также отсутствует объемный файл nodemon.json. Один инструмент отвечает за разработку, другой — за проверку типов, а третий — за работу в производственной среде. И чтобы было ясно: папка node_modules по-прежнему остается чрезмерно объемной — эта особенность экосистемы Node не изменилась с 2009 года.
Настоящая польза заключается не в том, что ts-node исчезает из списка ваших зависимостей. Суть в том, что вы перестаете считать, будто удаление ts-node одновременно удаляет и этап сборки. Работа с TypeScript нативно — это процесс сборки, который меньше по размеру, быстрее и более прозрачен, но это всё равно процесс сборки. Эта миграция — это не переход от «наличия процесса сборки» к «отсутствию процесса сборки». Это переход от этапа сборки, который был невидим, к этапу, который вы действительно можете понять.
Связанные статьи
- Что на самом деле делает и чего не делает нативная поддержка TypeScript в Node.js — В этой статье объясняется, как Node.js запускает файлы .ts нативно с помощью удаления типов, почему он пропускает проверку типов и когда вам всё же нужен настоящий этап сборки.