Нативна підтримка TypeScript у Node: 7 реальних проблем та їхні рішення
Дізнайтеся, які функції TypeScript потайки порушують роботу виробничого середовища через вбудоване прибирання типів у Node, та які саме налаштування допомагають вирішити ці проблеми, перевірені у Node 22.18+ та 24.x LTS.
Команда, яка мігрувала невеликий сервіс на Express, вирішила відмовитися від ts-node та запускати додаток безпосередньо за допомогою node file.ts у режимі staging. На локальних тестах ця заміна здавалася ефективною, але вже наступного робочого дня у процесі 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 під час генерації коду таз, щоб скомпільований 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, а також розгляньте можливість інтеграції цього кроку в hooks перед комітом, якщо це підходить вашій робочій схемі. Нативний середовище виконання запускає код; 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. Повністю видаліть поля 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"
}
}
Якщо ваша мета — serverless-середовища, edge-рунтайми чи користувачі на Node 20.x, вам все одно потрібен транспайлер — або swc, або tsc — налаштований так, щоб генерувати код, який зможе виконуватися старішими рунтаймами. Крок компіляції, який ви думали, що прибрали, все ще необхідний там.
Чи варто це було? Чесний вердикт.
Нативна підтримка TypeScript може бути найважливішим покращенням для Node з моменту появи async/await — але ці похвали супроводжуються значними обмеженнями.
Це має сенс впровадити, якщо:
- Ви розгортаєте самокерований сервіс на Node 22.18+ або у лінійці 24.x LTS.
- Ви вже пишете чистий, легко очищуваний TypeScript — без енумерацій, без декораторів, виключно зі стандартною синтаксисом ESM.
- У вас є завдання CI, яке запускає
tsc --noEmit, щоб перевірка типів не зникала непомітно з вашого робочого процесу.
node_modules.Kраще залишитися з 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 нативно шляхом видалення типів, чому він пропускає перевірку типів та коли вам все одно потрібен справжній крок компіляції.