Wbudowana obsługa TypeScript w Node: 7 rzeczywistych problemów i rozwiązań
Dowiedz się, które funkcje TypeScript są w tle niszczone przez wbudowane narzędzie do usuwania typów w Node podczas pracy w środowisku produkcyjnym, oraz jakie dokładne ustawienia naprawiają ten problem i zostały sprawdzone w Node 22.18+ oraz 24.x LTS.
Zespół migrujący małą usługę Express postanowił zrezygnować z ts-node i uruchamiać aplikację bezpośrednio za pomocą node file.ts w środowisku staging. Ta zmiana wydawała się poprawna podczas testów lokalnych, ale już następnego dnia praca w pipeline’u CI zaczęła zawodzić, niektórzy programiści widzieli w swoich terminalach błąd ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, a w produkcji wdrożono pilną poprawkę bez żadnej kontroli typów, ponieważ ta zabezpieczająca mechanizm po cichu zniknęła.
Wbudowana w Node obsługa TypeScript poprzez usuwanie informacji typowych to solidna funkcjonalność. Jednak obejmuje ona węższy zakres możliwości tego języka, niż przypuszczają większość ludzi, a luki te stają się widoczne dopiero w praktyce. Poniżej przedstawiono siedem problemów, które pojawiły się podczas rzeczywistej migracji, wraz z dokładnymi błędami i sprawdzonymi rozwiązaniami dla wersji Node 22.18+ oraz 24.x LTS.
Zapowiedzi a rzeczywistość
Node wykonywać kod napisany w TypeScript poprzez usuwanie adnotacji typów w czasie wykonywania za pomocą wbudowanej wersji swc. Nigdy nie korzysta z kompilatora TypeScript – nie ma żadnej fazy tsc. W rezultacie nie odbywa się żadna weryfikacja typów, brak transformacji składni dla starszych celów, nie rozwiązuje się aliasów ścieżek, brak obsługi plików .tsx, brak wsparcia dla dekoratorów, nie ma enum, a także nie generuje się kodu w czasie wykonywania dla przestrzeni nazw.
Ruchliwe uruchomienie polecenia node file.ts jest możliwe i stanowi rzeczywistą funkcjonalność, ale reprezentuje jedynie minimalną funkcjonalność, a nie pełny zestaw możliwości TypeScript.
1. Moje względne importy w produkcji milcząco zwracały błąd 404
Symptomy. Wszystko działało lokalnie. Jednak po wdrożeniu aplikacja zawodziła przy uruchamianiu z błędem w tym rodzaju:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js
Co się stało. Usuwanie typów usuwa jedynie adnotacje; pozostawia wszystkie inne elementy pliku nietknięte, włączając ścieżki importów. Dlatego plik źródłowy taki jak ten:
// src/server.ts
import { hashToken } from "./utils/hash.js";
przechodzi bez żadnych zmian. Podczas rozwoju lokalnego node --experimental-strip-types potrafił poprawnie odnaleźć plik ./utils/hash.ts, mimo że jego rozszerzenie wskazywało .js. Jednak proces budowania (kompilacja za pomocą tsc do folderu dist) zachował dosłowne rozszerzenie .js w ciągu tekstu, a w folderze dist/utils/ nie znajdował się odpowiadający mu plik .js — skompilowane zostały tam tylko pliki źródłowe w formacie .ts.
Rozwiązanie. Konieczne są dwie oddzielne zmiany wprowadzone jednocześnie.
Najpierw zapisz rozszerzenie importu tak, aby odpowiadało temu, co faktycznie znajduje się na dysku — czyli .ts, a nie .js:
// src/server.ts
import { hashToken } from "./utils/hash.ts";
Następnie poinformuj kompilator, że jest to celowe, i pozwól mu przetłumaczyć rozszerzenie podczas generowania pliku:
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}
Kluczowym ustawieniem jest rewriteRelativeImportExtensions: true, które przekształca ./utils/hash.ts z powrotem na ./utils/hash.js, gdy tsc generuje plik wyjściowy, dzięki czemu skompilowany JavaScript nadal poprawnie funkcjonuje we wszystkich aplikacjach, które go używają. noEmit: true nie jest tutaj opcjonalne — bez niego allowImportingTsExtensions powoduje błąd TS5096. Szczegóły znajdziesz w dokumentacji TypeScript.
2. Połowa mojego kodu bazowego miała „nieobsługiwaną składnię”
Symptom. Jeden z programistów napotkał ten błąd już przy swoim pierwszym commicie po zmianie:
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.
Inny napotkał podobne problemy z konstruktorem klasy:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.
Co się stało. Silnik usuwania informacji o typach w Node celowo obsługuje tylko ograniczoną kategorię składni: konstrukcje, które można całkowicie usunąć bez zmiany zachowania w czasie wykonywania, znane jako składnia „erasable”. Każda funkcja TypeScript, która faktycznie generuje logikę w JavaScriptu w czasie wykonywania, jest odrzucana z wyjątkiem w czasie wykonywania zamiast być przekształcana.
Pełna lista nieobsługiwanych konstrukcji, zaczerpnięta z oficjalnej dokumentacji Node TypeScript, obejmuje: deklaracje enum, które muszą zostać przekształcone w zbiór ciągów znakowych lub obiekt przy użyciu as const; bloki namespace zawierające logikę w czasie wykonywania, których wartości eksportowane muszą zostać przeniesione do zwykłych eksportów modułu (namespace zawierające wyłącznie typy są w porządku); właściwości parametrów w konstruktoraх (takie jak constructor(public x: number)), które wymagają zamiast tego wyraźnej deklaracji pola; aliasy importu, które muszą zostać przemianowane w momencie importu; dekoratory, które zawodzą na poziomie parsera i nie są uzupełniane; oraz pliki .tsx, ponieważ rozpoznawane są jedynie rozszerzenia .ts, .mts i .cts.
Rozwiązanie. Oto jak rozstrzygany jest przypadek 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];
Dla dekoratorów najbezpieczniejszym podejściem jest czekanie, aż parser Node będzie natywnie obsługiwał propozycję dekoratorów TC39, zanim je wdrożymy, albo używanie transformatorów takich jak swc lub tsc w procesie przetwarzania specjalnie dla plików, które od nich zależą. Warto również włączyć "erasableSyntaxOnly": true w pliku tsconfig.json — dzięki temu kompilator wykrywa nieobsługiwaną składnię bezpośrednio w edytorze, zanim dotrze ona do czasu wykonywania.
3. Sprawdzanie typów nie odbywa się w tle
Symptom. Obsługa produkcji przyjęła wartość null, podczas gdy oczekiwano wartości typu string, co spowodowało awarię przy wywołaniu metody .length na tej wartości. Test jednostkowy dla tej ścieżki przeszedł bez żadnych problemów. Zmienna była zadeklarowana jako string, a rzeczywista wartość to null; mimo to plik node file.ts uruchomił się bez zastrzeżeń.
app.post("/webhook", (req, res) => {
const body: string = req.body.payload; // null sneaks in, no one notices
console.log(body.length);
});
Co się stało. Proces usuwania informacji o typach odbywa się na czysto tekstowym poziomie — nigdy nie konsultuje on kontrolera typów. W ścieżce wykonywania w node nic nie sprawdza, czy wartości przepływające przez kod odpowiadają deklarowanym typom.
To jest chyba najbardziej ryzykowny sposób cichego awarii, jaki pojawił się po odejściu od ts-node. Pomyślne uruchomienie node file.ts nic nie mówi o tym, czy kod jest poprawny pod względem typów.
Rozwiązanie. Ponownie wdrożenie sprawdzania typów jako odrębnego, wyraźnego kroku.
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"ci": "npm run typecheck && npm run lint"
}
}
Uruchom tsc --noEmit jako część procesu CI przy każdym pull requestu i rozważ połączenie tego z hookami pre-commit, jeśli pasuje to do twojego procesu pracy. Natywny środowisko wykonywania uruchamia kod; tsc to narzędzie odpowiedzialne za wykrywanie błędów typowych. Te dwa aspekty są teraz całkowicie oddzielone, a to rozdzielenie jest zamierzone w projekcie Node.
Warto również włączyć "erasableSyntaxOnly": true razem z "verbatimModuleSyntax": true w pliku tsconfig.json. Pierwsza ustawienie sprawia, że narzędzie tsc odrzuca wszystko, czego nie może obsłużyć mechanizm usuwania typów — dzięki temu dekoratory i enum są wykrywane w czasie kompilacji, a nie w czasie wykonywania. Druga z nich wymusza użycie wyraźnych instrukcji import type, aby importy obejmujące tylko typy nie pozostawiały po sobie niepotrzebnych instrukcji importu w czasie wykonywania.
4. Moje aliasy ścieżek @/utils/* przestały działać
Symptomy. Znany błąd:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts
Co się działo. Pole paths w pliku tsconfig.json jest wyłącznie ułatwieniem podczas kompilacji dla TypeScript — sam Node nigdy go nie rozumiał. Narzędzia takie jak ts-node i tsx je obsługiwały, ponieważ zaimplementowały własną logikę rozwiązywania modułów na bazie Node. Narzędzie do usuwania typów natywnych tego nie robi; przekazuje zadanie bezpośrednio do ładowacza Node.
// tsconfig.json ; this never worked at runtime, it only worked in your editor
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/utils/*": ["src/utils/*"] }
}
}
Rozwiązanie. Istnieją trzy prawomocne drogi postępowania, w zależności od sposobu rozpakowywania aplikacji.
Opcja A: użyj wbudowanych importów podpath w Node. Usuń całkowicie pola tsconfig i zadeklaruj mapowanie zamiast tego w pliku package.json:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
// src/server.ts
import { logger } from "#utils/logger.ts";
To rozwiązuje problem poprawnie w środowisku node, przy użyciu tsc --noEmit oraz w vitest, bez konieczności dodatkowej konfiguracji. Pierwszy znak # to własna konwencja Node’a wskazująca, że „to jest wewnętrzny alias, a nie opublikowane pakiety”. Migracja polega po prostu na przeszukaniu całego projektu i zastąpieniu @/utils/ przez #utils/.
Opcja B: używanie importów względnych i akceptacja łańcuchów ../. Jest to pracochłonne, ale nie wymaga żadnych ukrytych narzędzi.
Opcja C: zachowanie transformatora do przepisywania ścieżek. Narzędzia takie jak tsc-alias przepisują wygenerowany JavaScript po skompilowaniu, albo można pozwolić tsx/swc rozwiązywać aliasy w czasie wykonywania. Dzięki temu ponownie pojawia się krok budowania, którego próbowano uniknąć, co umniejsza zalety natywnego TypeScript. To nie jest droga godna podjęcia w perspektywie 2026 roku.
5. Współpraca CommonJS i ESM zaskoczyła mnie
Symptom. Wywołanie require("openai"), które zawsze działało, nagle rzuciło błąd:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.
Lub, w przeciwnym kierunku, import z katalogu bez rozszerzenia spowodował błąd:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM
Co się działo. Wcześniej plik źródłowy był kompilowany przez tsc do pliku .js w katalogu dist/, a funkcja require() działała zgodnie z oczekiwaniami. Przy natywnym uruchamianiu TypeScript plikiem, który faktycznie ładuje Node, jest sam plik .ts, a Node decyduje, czy traktować go jako CommonJS, czy ESM, na podstawie pola "type" najbliższego pliku package.json. Jeśli to pole zawiera wartość "module", wszystkie pliki .ts w tym zakresie są traktowane jako ESM, więc wszelkie pozostałe wywołania require() powodują błędy. Jeśli to pole jest nieobecne (co oznacza domyślnie CommonJS), pojawia się problem przeciwny: importowanie zależności dostępnej tylko w formacie ESM nie udaje się.
Rozwiązanie. Wybierz jeden system modułów i egzekwuj jego stosowanie w całym projekcie.
Jeśli zaczynasz od nowa, ustaw "type": "module" w pliku package.json, zapisuj wszystko w formacie ESM i przeznacz rozszerzenia .mts/.cts dla tych nielicznych plików, które rzeczywiście wymagają innego formatu.
// 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();
W przypadku istniejącej bazy kodu CommonJS zachowaj wartość "type": "commonjs" (lub pomiń to pole) — unikaj prób włączania czystego pakietu ESM z kodu CommonJS bez użycia dynamicznego import(). Node 22.12+ obsługuje już stabilną funkcję require(esm), ale poleganie na niej nadal naraża na problemy związane z dwoma formatami pakietów i sprawia, że proces budowania jest niestabilny. Bezpieczniejszymi rozwiązaniami są konwersja pliku wywołującego na format ESM lub umieszczenie zależności w dynamicznym importie wewnątrz funkcji async.
Istnieje tu druga pułapka: importy z katalogów. W środowisku ESM zapis import x from "./folder" nie rozwiąże automatycznie nazwy pliku na ./folder/index.ts, jak to mogło być wcześniej. Musisz wyraźnie podać nazwę pliku:
// bad
import { routes } from "./routes";
// good
import { routes } from "./routes/index.ts";
6. Tryb obserwacji i szybkie ładowanie cofnęły się o krok
Symptomy. Po przejściu z tsx watch src/server.ts na node --watch src/server.ts kilka aspektów uległo pogorszeniu:
- Szybkość ponownego uruchomienia —
node --watchdziała, ale jest znacznie wolniejszy. - Niezawodne ładowanie po zmianie w pliku importowanym spoza korzenia projektu.
- Możliwość ręcznego ponownego uruchomienia za pomocą
SIGUSR2. - Kolorowe wyjście oraz przyjazne powiadomienie „naciśnij R, aby ponownie uruchomić”.
node_modules, dist oraz .test.ts.Co się działo. node --watch to od dawna istniejący wbudowany narzędzie do obserwacji plików w Node. Dzięki usuwaniu informacji typowych teraz funkcjonuje również z plikami .ts, ale nigdy nie został zaprojektowany jako pełna zamienna dla dedykowanych narzędzi takich jak tsx watch czy nodemon – jest raczej podstawową funkcją.
Rozwiązanie. Używaj node --watch, gdy potrzebujesz jedynie ponownego uruchomienia po jakiejkolwiek zmianie w pojedynczym skrypcie. W przypadku prawdziwego serwera z łańcuchem importów oraz rzeczywistym cyklu rozwojowym lub testowego, lepiej pozostać przy tsx watch. Nie ma nic złego w dalszym używaniu tsx jako narzędzia do rozwoju, nawet po przeniesieniu wykonywania w produkcji na natywny 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 działa na esbuild i jest znacznie szybszy od tsc — według własnych testów projektu około 20 do 30 razy szybszy — rozumie aliasy ścieżek bez dodatkowych ustawień i zachowuje się tak, jak node --watch, gdyby tryb obserwacji otrzymał większą uwagę od twórców.
7. Pominięcie kroku budowania jedynie przeniosło problem
Symptomy. Po ogłoszeniu, że zespół porzuci tsc na rzecz natywnego wykonywania kodu, niemal natychmiast pojawiło się kilka problemów:
- SDK opublikowane na npm wymagało plików deklaracji
.d.tsdla użytkowników końcowych. Natywne wykonywanie TypeScript nie generuje takich plików. - Cel implementacji w Lambda oczekiwał wyjścia w formacie CommonJS, podczas gdy kod został napisany w formacie ESM.
.ts, zamiast skompilowanych plików.Co się stało. Usuwanie elementów kodu odbywa się w czasie wykonywania, a nie podczas kompilacji — to właśnie jest sens tej funkcji. Jednak gdy Twój kod musi działać gdzieś poza „Node 22 lub nowszy, uruchamiany bezpośrednio z repozytorium”, znów potrzebny jest krok kompilacji. Nie zniknął on; po prostu przeniósł się do innej części procesu.
Rozwiązanie. Musisz precyzyjnie określić, co tak naprawdę wysyłasz.
Jeśli budujesz aplikację — usługę, którą sam rozprowadzasz i uruchamiasz — natywny TypeScript stanowi prawdziwe ulepszenie. Nie ma konieczności kompilacji, starty są szybsze, a plik Dockerfile staje się prostszy, ponieważ możesz po prostu wpisać COPY src ./src zamiast zarządzać folderem 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"]
Jeśli utrzymujesz bibliotekę przeznaczoną do npm, zachowaj tsc do faktycznego kroku generowania plików. Możesz swobodnie używać natywnego TypeScript podczas rozwoju i testowania, ale opublikowany pakiet nadal musi zawierać skompilowane pliki .js wraz z plikami .d.ts.
// package.json ; library case
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"test": "node --test --experimental-strip-types test/*.test.ts"
}
}
Jeśli twoim celem są serwery bez zarządzania, środowiska działające na brzegu lub aplikacje uruchamiane w Node 20.x, nadal potrzebujesz transpilatora — albo swc, albo tsc — skonfigurowanego tak, aby generował kod, który starsze środowiska mogą wykonać. Krok budowania, który myślałeś, że usunąłeś, nadal jest tam konieczny.
Czy było to warte? Szczerą ocenę.
Wbudowana obsługa TypeScript może być najważniejszym ulepszeniem w Node od pojawienia się async/await — ale ta pochwała idzie w parze z istotną zastrzeżeniem.
Ma sens ją przyjąć, jeśli:
- Rozwijasz samodzielnie zarządzany serwis w Node 22.18+ lub w wersjach LTS 24.x.
- Już piszesz czysty, łatwy do edycji kod w TypeScript — bez enum, bez dekoratorów, wyłącznie z użyciem składni ESM.
- Masz zadanie CI, które uruchamia
tsc --noEmit, aby sprawdzanie typów nie zniknęło potajemnie z twojego procesu pracy.
node_modules.Lepiej pozostać przy tsx lub tsc, jeśli:
- Publikujesz bibliotekę, która musi działać na starszych wersjach Node dla twoich użytkowników.
- Jesteś silnie zależny od NestJS, TypeORM, class-validator lub innych narzędzi opartych na eksperymentalnych dekoratorach.
- Potrzebujesz obsługi plików
.tsxdla komponentów React renderowanych na serwerze. - Opierasz się na aliasach ścieżek w
tsconfigi nie jesteś gotowy na przejście na poleimports. - Jeszcze nie masz dyscypliny (lub odpowiednich narzędzi), aby unikać w kodzie składni, której nie można usunąć.
Ustawienia, które ostatecznie umożliwiły to działanie:
// 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"
}
}
To jest cała konfiguracja. Nie widać żadnego ts-node. Brak też tsc w ścieżce wykonywania w czasie rzeczywistym. Nie ma też rozbudowanego pliku nodemon.json. Jeden narzędzie zajmuje się rozwojem, drugie weryfikacją typów, a trzecie działaniem w produkcji. I żeby było jasne – node_modules nadal jest tak samo objętościowy jak zawsze; ten konkretny aspekt ekosystemu Node nie uległ zmianie od 2009 roku.
Prawdziwą korzyścią tutaj nie jest to, że ts-node zostanie usunięty z Twoich zależności. Chodzi o to, że przestajesz wierzyć, iż usunięcie ts-node oznacza również usunięcie kroku budowania. Natywna eksploatacja TypeScript to mniejszy, szybszy i bardziej przejrzysty proces budowania — ale nadal jest to proces budowania. Ta migracja nie polega na przejściu od „posiadania procesu budowania” do „braku procesu budowania”. Jest to przejście od kroku budowania, którego nie mogłeś zobaczyć, do takiego, który faktycznie rozumiesz.
Literatura pokrewna
- Co tak naprawdę robi, a czego nie robi natywna obsługa TypeScript w Node.js — Ten artykuł wyjaśnia, jak Node.js uruchamia pliki .ts w sposób natywny poprzez usuwanie informacji typów, dlaczego pomija sprawdzanie typów oraz kiedy nadal potrzebny jest rzeczywisty krok budowania.