Галоўная / Артыкулы / Адаптаванне Node для TypeScript: 7 рэальных проблем і спосабаў их адраджэння

Адаптаванне Node для TypeScript: 7 рэальных проблем і спосабаў их адраджэння

Дазвольце дазнаць, які функцыяны TypeScript таяйна паслабляюць вбудованыя механізмы адсёлення типаў у Node ў рэальных умовах, а таксама якія саме настройкі дапамагаюць выправіць гэтыя проблемы, перакананыя на Node 22.18+ і 24.x LTS.

2809 слоў

Команда, яка мігрувала невеликую службу 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, якія маюць логіку часу выканання, і для яых неабходна пераказаць экспортаваных значэнняў у звычныя экспорты модуля (назвы прастых назваў нічога не змянююць); атрыбуты параметраў у канстракторах (напрыклад, constructor(public x: number)), для яых патрэбна чыстая заява поля; аліясы імпорту, якія павінны быць перайменованы ў момент імпорту; дэкоратары, якія не працуюць на рэвалюйчым роўні і не падлягаюць паліфілінгу; а таксама файлы .tsx, адтолькі ўпарцаваныя расшырэння .ts, .mts і .cts ўзнімляюцца.

Рашэння. Як адгукуюцца кейсы enum:

// 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. Першая настройка змушвае тулк адхіляць усё, чыяе обробкі не можа здарыцца падчас адзьёмленьня типаў — такім чынам декоратары і энумы ловяцца ў час компілявання, а не ў час выконання. Другая настройка вымагае явных запісаў 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, на адной з пазначэнняў package.json — поля "type". Якщо гэтае пазначэнне мае значэнне "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 і ў 20–30 разоў быстрэй за tsc — такія показнікі булі зафіксаваны самім проектам за дапамою власных тэстаў — ён з самага пачатку падтрымлівае аляскі пацоў, і яго працэс супадае з працэсам node --watch, якбы режым спостерэння заслужыў большай увагі.

    7. Адкладэнне крока будовы толькі перасунула проблему

    Сімптамы. Пасля таго, як команда анунцавала, што замест tsc будзе викорыстоўвана натыўная рэалізацыя, майже адразу выйшла некалькі проблем:

    • SDK, выданыя на npm, патрабавалі файлы заявленняў .d.ts для наступных корыстнікаў; натыўная експанацыя TypeScript іх не стварае.
    • Цэль размешчэння коду у Lambda чакала выхід у формате CommonJS, але код быў напісаны ў формате ESM.
  • Колега, які ўсё仍 працуе на Node 20.x, прабаваў запісаць пакет, але не змог — средавыя умовы не падтрымвалі функцыю адсёкання дэтаў, на якую ён раслыкаўся.
  • Зображэнне Docker стала большым, таму што тепер перасылаліся сырые файлы коду .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-рэшты, рэшты на краю сеті або прыемнікі на Node 20.x, вам все рава патрэбны транспіляторы — адзін з swc або tsc — налаштаваныя так, каб выдаваць тое, што можа запрацаваць у старэйшых рэштах. Крок падготовкі, які вы думалі, што адмініструеце, там усё рава неабходны.

    Ці це было вартага? Чыстая адпаведна.

    Натыўная падтрымка TypeScript можа быть найзначнейшым удосконаленнем для Node з моменту появы async/await — але гэта хваляванне супакоўваецца значным застерэжэннем.

    Это мае сенс адмацаваць, якщо:

    • Вы размешчаеце самадзяржаны сервіс на Node 22.18+ або у лінейцы 24.x LTS.
    • Вы вже пішалі чысты, можна выдаліць TypeScript — без enum, без дэкоратараў, толькі стандартная сынтаксіс ESM.
    • У вас ёсць задача CI, якая запускае tsc --noEmit, каб перакананне типаў не зникло непазірна з вашага рабочага процесу.
  • Жадаеце быстрэйшага запуску, лёгкіях Dockerfile-аў і меншай колькасці залежнасцяў у node_modules.
  • Краща застацца з tsx або tsc, якщо:

    • Вы публікуеце бібліятэку, якая должна працаваць на старыях версіях Node для вашых корыстнікаў.
    • Вы сильна завісіце ад NestJS, TypeORM, class-validator чы іншых інструментаў, створаных на адной з эксперыментальных декоратараў.
    • Вам патрэбна падтрымка .tsx для компонентаў React, якія renderуюцца на серверы.
    • Вы выкарыстоўваете псевданімы шляхоў у 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 — это меншы, быстрэй і болей прозрачны процес будовы, але гэта все равно застаецца процесам будовы. эта міграцыя — не пераход з „наявнасці процесу будовы“ да „відсутнасці процесу будовы“. Це пераход з кроку будовы, які вы не моглі бачыць, да кроку, які вы насправдэўна розумеете.

    Спадні матэрыялы

  • Замена Jest на вбудованага тэст-раннера Node у Node 24 — Практычны прыклад міграцыі паказвае, як вбудованы тэст-раннер Node 24 і падтрымка TypeScript дапамагаюць скорачыць час аўтаматызаванага тэставання, адмахнуўшыся чатырох залежнасцяў.