This article is published in English.
Node's Native TypeScript Support: 7 Real-World Breakages and Fixes
Learn which TypeScript features Node's built-in type stripping silently breaks in production, and the exact config fixes verified on Node 22.18+ and 24.x LTS.
A team migrating a small Express service decided to drop ts-node and run the app directly with node file.ts in staging. The switch looked clean in local testing, but by the next work day the CI pipeline had failing builds, some developers were hitting ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX in their terminals, and a production hot-fix had shipped without any type checking at all, because that safety net had quietly disappeared.
Node's native handling of TypeScript, through type stripping, is a solid capability. But it covers a narrower slice of TypeScript than most people assume, and the gaps only become obvious once you hit them in practice. Below are seven issues that surfaced during a real migration, along with the actual errors produced and fixes verified against Node 22.18+ and 24.x LTS.
The pitch vs. the reality
Node executes TypeScript by removing the type annotations at runtime using a built-in copy of swc. It never calls out to the TypeScript compiler. There's no tsc phase involved. Consequently, there's also no type checking, no syntax transformation for older targets, no resolution of path aliases, no .tsx support, no decorator support, no enums, and no runtime code generation for namespaces.
Running node file.ts works, and it's a real capability, but it represents the minimum functionality, not the full feature set of TypeScript.
1. My relative imports silently 404'd in production
The symptom. Everything worked locally. But once deployed, the app failed on startup with an error like this:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/dist/utils/hash.js'
imported from /srv/app/dist/server.js
What happened. Type stripping only removes annotations; it leaves every other token in the file untouched, including import paths. So a source file like this:
// src/server.ts
import { hashToken } from "./utils/hash.js";
is passed through unmodified. During local development, node --experimental-strip-types was smart enough to resolve ./utils/hash.ts even though the extension said .js. But the build process (compiling with tsc into a dist folder) kept the literal .js extension in the string, and there was no matching .js file inside dist/utils/ — only .ts sources had been compiled elsewhere.
The fix. Two separate changes are needed together.
First, write the import extension to match what's actually on disk — meaning .ts, not .js:
// src/server.ts
import { hashToken } from "./utils/hash.ts";
Second, tell the compiler this is deliberate, and let it translate the extension during emit:
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}
The key setting is rewriteRelativeImportExtensions: true, which converts ./utils/hash.ts back into ./utils/hash.js when tsc emits output, so the compiled JavaScript still works correctly for anything consuming it downstream. noEmit: true isn't optional here — without it, allowImportingTsExtensions triggers a TS5096 error. See the TypeScript documentation for details.
2. Half my codebase was "unsupported syntax"
The symptom. One developer hit this error on their very first commit after the switch:
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.
Another ran into a similar wall with a class constructor:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter properties
are not supported. Use an explicit field declaration instead.
What happened. Node's type-stripping engine deliberately supports only a limited category of syntax: constructs that can be deleted outright without altering runtime behavior, known as "erasable" syntax. Any TypeScript feature that actually generates JavaScript logic at runtime gets rejected with a runtime exception instead of being transformed.
The complete list of unsupported constructs, taken from the official Node TypeScript documentation, includes: enum declarations, which must become a string union or an object using as const; namespace blocks that contain runtime logic, which need their exported values moved into plain module exports (type-only namespaces remain fine); parameter properties in constructors (like constructor(public x: number)), which require an explicit field declaration instead; import aliases, which must be renamed at the point of import; decorators, which fail at the parser level and aren't polyfilled; and .tsx files, since only .ts, .mts, and .cts extensions are recognized.
The fix. Here's how the enum case gets resolved:
// 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];
For decorators, the safest approach is to wait until Node's parser natively supports the TC39 decorator proposal before adopting them, or to keep a transformer such as swc or tsc in the pipeline specifically for files that rely on them. Turning on "erasableSyntaxOnly": true in tsconfig.json is worthwhile too — it makes the compiler flag unsupported syntax directly in your editor before it ever reaches runtime.
3. Type checking silently doesn't happen
The symptom. A production handler accepted a null value where a string was expected and crashed when calling .length on it. The unit test for that route passed without complaint. The variable was typed as string, the actual value was null, and node file.ts executed both without objection.
app.post("/webhook", (req, res) => {
const body: string = req.body.payload; // null sneaks in, no one notices
console.log(body.length);
});
What happened. Type stripping works on a purely textual level — it never consults the type checker. Nothing in the node execution path verifies that the values flowing through your code match their declared types.
This is arguably the riskiest silent failure mode introduced by moving away from ts-node. A successful run of node file.ts says nothing about whether the code is type-correct.
The fix. Reintroduce type checking as a separate, explicit step.
// package.json
{
"scripts": {
"dev": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"ci": "npm run typecheck && npm run lint"
}
}
Run tsc --noEmit as part of CI for every pull request, and consider wiring it into pre-commit hooks if that fits your workflow. The native runtime executes the code; tsc is the tool responsible for catching type errors. These two concerns are now fully decoupled, and that separation is intentional in Node's design.
It's also worth enabling "erasableSyntaxOnly": true together with "verbatimModuleSyntax": true in tsconfig.json. The first setting makes tsc reject anything that type stripping can't handle — catching decorators or enums at compile time rather than at runtime. The second enforces explicit import type statements so type-only imports don't leave behind stray runtime import statements.
4. My @/utils/* path aliases stopped working
The symptom. A familiar error:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '@/utils/logger'
imported from /srv/app/src/server.ts
What was going on. The paths field in tsconfig.json is purely a compile-time convenience for TypeScript — Node itself has never understood it. Tools like ts-node and tsx honored it because they implemented their own module resolution logic on top of Node. Native type stripping doesn't do that; it hands resolution straight to Node's loader.
// tsconfig.json ; this never worked at runtime, it only worked in your editor
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/utils/*": ["src/utils/*"] }
}
}
The fix. There are three legitimate paths forward, depending on how your app is deployed.
Option A: use Node's built-in subpath imports. Remove the tsconfig paths entirely and declare the mapping in package.json instead:
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
// src/server.ts
import { logger } from "#utils/logger.ts";
This resolves correctly under node, under tsc --noEmit, and under vitest, with no extra configuration required anywhere. The leading # is Node's own convention signaling "this is an internal alias, not a published package." Migrating just means doing a project-wide search-and-replace from @/utils/ to #utils/.
Option B: fall back to relative imports and live with the ../ chains. It's tedious, but there's no hidden tooling involved.
Option C: keep a path-rewriting transformer. Tools like tsc-alias rewrite the emitted JavaScript after compilation, or you can let tsx/swc resolve the aliases at runtime. Doing this reintroduces the build step you were trying to eliminate, which undercuts much of the appeal of native TypeScript. This isn't a route worth taking going into 2026.
5. CommonJS and ESM interop caught me off guard
The symptom. A require("openai") call that had always worked suddenly threw:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... openai ... not supported.
Or, in the opposite direction, a directory import without an extension broke:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import ... is not supported
under ESM
What was going on. Previously your source file was compiled by tsc into a .js file in dist/, and require() behaved as expected. With native TypeScript execution, the file Node actually loads is the .ts file itself, and Node determines whether to treat it as CommonJS or ESM based on the nearest package.json's "type" field. If that field says "module", every .ts file in scope is ESM, so any lingering require() calls break. If the field is missing (which defaults to CommonJS), the opposite problem shows up: importing an ESM-only dependency fails.
The fix. Commit to one module system and enforce it project-wide.
If you're starting fresh, set "type": "module" in package.json, write everything as ESM, and reserve the .mts/.cts extensions for the occasional file that genuinely needs to be the other format.
// 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();
For an existing CommonJS codebase, keep "type": "commonjs" (or leave the field out) — and avoid trying to pull in a pure ESM package from CommonJS code without going through a dynamic import(). Node 22.12+ does support a stable require(esm), but relying on it still exposes you to dual-package hazards and makes your build fragile. The safer options are converting the calling file to ESM, or wrapping the dependency in a dynamic import inside an async function.
There's a second trap here: directory imports. Under ESM, writing import x from "./folder" won't automatically resolve to ./folder/index.ts the way it might have before. You need to spell out the file explicitly:
// bad
import { routes } from "./routes";
// good
import { routes } from "./routes/index.ts";
6. Watch mode and hot reloading took a step backward
The symptom. After switching from tsx watch src/server.ts to node --watch src/server.ts, several things got worse:
- Restart speed —
node --watchworks, but it's noticeably less snappy. - Reliable reloading when a change happens in an imported file outside the project root.
- The ability to trigger a manual restart via
SIGUSR2. - The colored output and the friendly "press R to restart" prompt.
- Sensible default exclusions for
node_modules,dist, and.test.tsfiles.
What was going on. node --watch is Node's long-standing built-in file watcher. It now works against .ts files thanks to type stripping, but it was never designed to be a full substitute for dedicated tools like tsx watch or nodemon — it's closer to a baseline feature.
The fix. Reach for node --watch when you just need a hard restart on any change to a single script. For a real server with a chain of imports and an actual development or test loop, stick with tsx watch. There's nothing wrong with continuing to use tsx as your development runner even after moving production execution to native 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 runs on esbuild and is dramatically faster than tsc — roughly 20 to 30 times faster by the project's own benchmarks — it understands path aliases out of the box, and it behaves the way node --watch would if watch mode had received more product attention.
7. Skipping the build step just relocated the problem
The symptom. After announcing that the team would drop tsc in favor of running things natively, a handful of issues surfaced almost immediately:
- The SDK published to npm needed
.d.tsdeclaration files for downstream consumers. Native TypeScript execution doesn't generate them. - The Lambda deployment target expected CommonJS output, but the code was written as ESM.
- A colleague still on Node 20.x tried to install the package and simply couldn't — the runtime didn't support the stripping behavior being relied on.
- The Docker image grew larger, because raw
.tssource files were now being shipped instead of compiled output.
What happened. Type stripping happens at runtime, not at build time — that's the whole point of the feature. But the instant your code needs to run somewhere other than "Node 22 or newer, executing straight from your repository," you're back to needing a compilation step. It didn't disappear; it just moved to a different part of the pipeline.
The fix. Get specific about what kind of thing you're actually shipping.
If you're building an application — a service you deploy and run yourself — native TypeScript is a genuine improvement. There's no build, cold starts are faster, and the Dockerfile gets simpler since you can just COPY src ./src instead of managing a dist/ folder.
# 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"]
If you're maintaining a library destined for npm, keep tsc around for the actual emit step. Feel free to run native TypeScript during development and testing, but the published package still needs to ship compiled .js alongside .d.ts files.
// package.json ; library case
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"test": "node --test --experimental-strip-types test/*.test.ts"
}
}
If your target is serverless, edge runtimes, or consumers on Node 20.x, you still need a transpiler — either swc or tsc — configured to output something that older runtime can execute. The build step you thought you eliminated is still required there.
Was it worth it? The honest verdict.
Native TypeScript support may be the most significant improvement to Node since async/await arrived — but that praise comes with a substantial caveat.
It makes sense to adopt if you:
- Deploy a self-managed service on Node 22.18+ or the 24.x LTS line.
- Already write clean, erasable TypeScript — no enums, no decorators, plain ESM syntax throughout.
- Have a CI job running
tsc --noEmitso that type checking doesn't quietly vanish from your workflow. - Want quicker cold starts, leaner Dockerfiles, and one fewer dependency living in
node_modules.
It's better to stick with tsx or tsc if you:
- Publish a library that has to run on older Node versions for your consumers.
- Depend heavily on NestJS, TypeORM, class-validator, or other tooling built around experimental decorators.
- Need
.tsxsupport for server-rendered React components. - Rely on
tsconfigpath aliases and aren't ready to switch over to theimportsfield. - Don't yet have the discipline (or tooling) to keep non-erasable syntax out of your codebase.
The setup that ultimately made this workable:
// 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"
}
}
That's the whole configuration. No ts-node in sight. No tsc sitting in the runtime execution path. No sprawling nodemon.json file. One tool handles development, one handles type checking, and one handles production. And to be clear, node_modules is still just as bloated as ever — that particular aspect of the Node ecosystem hasn't budged since 2009.
The real payoff here isn't that ts-node gets deleted from your dependencies. It's that you stop believing that deleting ts-node also deleted your build step. Native TypeScript execution is a smaller, faster, and more transparent build process — but it's still a build process. This migration isn't a move from "having a build" to "having no build." It's a move from a build step you couldn't see to one you actually understand.