Breaking
Fix ERR_UNKNOWN_FILE_EXTENSION “.ts” in ts-node

Fix ERR_UNKNOWN_FILE_EXTENSION “.ts” in ts-node

ts-node's native ESM support has been a long-running pain point since 2020, and it still isn't reliable. On a type:module project, the cleaner answer is to stop using ts-node.

How this was written

Drafted in plain Markdown by Ethan Laurent and edited against current Node.js, framework and tooling docs. Every command, code block and benchmark in this article was run on Node.js 24 LTS before publish; if a step does not work on your machine the post is wrong, not you — email and I will fix it.

AI is used as a research and outline assistant only — never as a single-source author. Full editorial policy: About / How nodewire is written.

Tested on Node.js 24 LTS · last reviewed June 2026

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.

bash
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:

text
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:

bash
node -v                       # is this Node 22.18+ or 24? then native may be enough
  1. Your Node version. Native TypeScript needs Node 22.18+ or Node 24 LTS.
  2. Whether package.json has "type": "module". That’s what flips you onto the ESM loader where the error lives.
  3. 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 tsx or a real build, not bare node 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:

bash
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:

bash
npm install -D tsx
npx tsx src/index.ts
JSON
{
  "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:

bash
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 — including enum, parameter properties, runtime namespace, and import path aliases. For those, use tsx or 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.