You added "type": "module" to package.json to use modern imports, and the moment you did, ts-node src/index.ts stopped working: TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts". The same command worked yesterday in CommonJS. Now Node refuses to even acknowledge a .ts file exists. The ts-node unknown file extension .ts error is the collision of two things that don’t fit together cleanly — Node’s ESM loader and ts-node’s transpilation — and in 2026 the honest fix is usually to stop fighting it and switch tools.
Quick fix
Once a project is "type": "module", ts-node’s ESM execution path stops behaving like its reliable CommonJS require hook, and it leans on the --loader flag that Node deprecated in v20.6 — so .ts files fail with ERR_UNKNOWN_FILE_EXTENSION. Modern Node can now run simple TypeScript natively through type stripping, which is one of the fixes below. Easiest first: run the file with tsx (npx tsx src/index.ts), or on Node 22.18+ / Node 24 LTS run it natively with node src/index.ts (Node strips the types itself). Reserve ts-node for CommonJS projects where it still works fine.
npx tsx src/index.ts # works on any modern Node, ESM or CJS
node src/index.ts # Node 22.18+ / 24 LTS: native, no extra dependency
The error you’re staring at:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /app/src/index.ts
at Object.getFileProtocolModuleFormat (node:internal/modules/esm/get_format:218:9)
Check your version first
Before you pick a fix, check three things — they decide which option below is right for you:
node -v # is this Node 22.18+ or 24? then native may be enough
- Your Node version. Native TypeScript needs Node 22.18+ or Node 24 LTS.
- Whether
package.jsonhas"type": "module". That’s what flips you onto the ESM loader where the error lives. - Whether your code uses enums, namespaces, path aliases, or extensionless imports. Those need transformation or config that native type stripping won’t do — so you’ll want
tsxor a real build, not barenode file.ts.
If you searched for ERR_UNKNOWN_FILE_EXTENSION ts, this is almost always the same ts-node + ESM problem, regardless of which package threw it.
Why ts-node breaks on ESM
In a CommonJS project, ts-node registers a require hook that compiles .ts to JavaScript on the fly. Simple, and it works. Add "type": "module" and Node switches to the ESM loader, which resolves modules through a different pipeline — and that pipeline has a fixed idea of which extensions it understands (.js, .mjs, .cjs). .ts isn’t on the list, and unlike the require hook, hooking the ESM loader to add it has been awkward for years.
ts-node’s answer was --loader ts-node/esm, but Node deprecated the --loader flag in v20.6.0 in favor of module.register(), and ts-node’s ESM path has lagged the moving target. ts-node’s native ESM support has been a long-running pain point since 2020 — tracked in a still-open ESM-support issue with hundreds of comments — and the ts-node docs themselves note that ESM support leans on Node’s experimental loader APIs. This is the same ESM/CJS boundary that produces Cannot use import statement outside a module — the module system changed under the tool, and the tool didn’t keep up.
The wrong fix everyone tries first
The top search result is a wall of flags:
node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only src/index.ts
It sometimes runs. But you’re now depending on a deprecated Node flag, suppressing the warning that tells you it’s deprecated, and pinning your dev loop to a combination that the next Node minor can break. People also try flipping tsconfig module back and forth between ESNext and CommonJS, which just moves the error around. Stacking experimental flags to keep a fragile tool alive is the opposite of a fix — it’s a fuse.
Fix 1: tsx (the practical choice)
tsx is a TypeScript runner built for exactly this. It handles ESM and CommonJS, doesn’t use the deprecated --loader flag, resolves tsconfig paths, and starts faster than ts-node. The cleanest way to run TypeScript ESM in Node today is usually tsx or native Node type stripping — and for development, tsx is a drop-in:
npm install -D tsx
npx tsx src/index.ts
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
}
}
tsx watch even replaces nodemon for the common case. The one thing tsx deliberately doesn’t do is type-check — it strips and runs, exactly like Node’s native mode. Keep type safety in a separate step (tsc --noEmit) in CI and your editor, and let the runner just run. This is the setup I default to now, and it’s the one in the TypeScript + Node setup guide and the Fastify + TypeScript API guide.
Fix 2: native Node 22.18+ / Node 24 LTS, no runner
Recent Node runs TypeScript itself, so for many projects you don’t need a runner in development. Type stripping ships unflagged for erasable TypeScript syntax in Node 22.18.0 and later, and Node 24 LTS is the version I’d target:
node src/index.ts
node --watch src/index.ts # restart on change, still no dependency
Node’s type stripping is implemented through Amaro, which wraps SWC’s TypeScript parser; it removes the type annotations and executes the result. On current Node 24 LTS a simple node file.ts can work without ts-node — but only within Node’s type-stripping limits.
When native Node will not work. Native Node is not a full TypeScript runtime. It does not type-check, it ignores
tsconfig.json, and it will fail or need extra handling for syntax that requires transformation — includingenum, parameter properties, runtimenamespace, and import path aliases. For those, usetsxor a real build.
Production recommendation: for production apps, still build with tsc and run the compiled JavaScript from dist/. Use tsx or native Node mainly for development scripts, CLIs, local servers, and tooling — not as the thing that runs your deployed service.
Fix 3: keep ts-node, but only on CommonJS
If you have a CommonJS project (no "type": "module") and a working ts-node setup, there’s no reason to change it — ts-node’s require hook is fine there. The error only shows up when you go ESM. So a valid “fix” is simply not to switch that project to "type": "module" until you’re ready to move the runner too. Don’t adopt ESM and ts-node together and expect calm.
What to choose
For new work, use tsx in development and a real tsc build for production — that’s the combination that doesn’t surprise you. On Node 22.18+ or 24, try node src/index.ts first; if you don’t need tsconfig paths or enums, you may not need any runner. Leave ts-node on the CommonJS projects where it already works, and don’t bring it into an ESM project expecting its ESM mode to hold.
FAQ
Why does ts-node throw Unknown file extension “.ts” on ESM projects?
Once package.json has "type": "module", Node uses its ESM loader, which only recognizes .js, .mjs, and .cjs. ts-node’s ESM hook relies on Node’s --loader flag, deprecated in v20.6.0, and is unreliable. The result is ERR_UNKNOWN_FILE_EXTENSION for .ts. Use tsx or native Node instead.
What is the best way to run TypeScript with ESM in 2026?
Use tsx (npx tsx src/index.ts) — it handles ESM, doesn’t use deprecated flags, and resolves tsconfig paths. On Node 22.18+ or Node 24 LTS you can also run .ts files natively with node src/index.ts. Keep type-checking as a separate tsc --noEmit step since neither runner type-checks.
Can Node run TypeScript files directly?
Yes, on Node 22.18+ and Node 24 LTS. Node strips type annotations on the fly, so node src/index.ts works without ts-node or tsx. It does not type-check and does not read tsconfig.json, so path aliases, enums, and namespaces need extra handling, but for plain .ts files it runs with no dependency.
Does native Node replace ts-node completely?
No, not completely. Native type stripping removes erasable TypeScript syntax and runs the file, but it does not type-check, it ignores tsconfig.json, and it does not handle features that need transformation (enums, runtime namespaces, parameter properties, path aliases). For those, or for a production build, you still want tsx or tsc.
Is tsx better than ts-node?
For ESM and for development speed, yes. tsx supports ESM and CommonJS, avoids the deprecated --loader flag, resolves tsconfig paths, and has faster startup. ts-node is still fine for CommonJS projects, but for new or ESM projects tsx is the more reliable choice.
Should I use the –loader ts-node/esm flag?
Avoid it on new setups. --loader was deprecated in Node v20.6.0 in favor of module.register(), and ts-node’s ESM path is fragile against Node updates. It may run today and break on the next Node minor. Use tsx or native Node instead.
Does tsx type-check my code?
No. tsx strips types and runs, the same as Node’s native mode — it does not report type errors. Run tsc --noEmit separately in your editor and CI for type safety, and let tsx handle execution. Separating the two is intentional and keeps the dev loop fast.
