Node.js Error

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

Complete reference for TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" — what it means, every cause, and all modern fixes including tsx, Node 22+ --experimental-strip-types, native TypeScript execution, and custom loader hooks.

Quick Answer: Node.js throws ERR_UNKNOWN_FILE_EXTENSION when its ESM loader encounters a file extension it cannot execute — most often .ts when "type": "module" is set in package.json. The fastest modern fix is replacing ts-node with tsx (npx tsx src/index.ts), or on Node 22.6+ using node --experimental-strip-types src/index.ts, or on Node 23.6+ just node src/index.ts (TypeScript stripping is on by default).

What is ERR_UNKNOWN_FILE_EXTENSION?

ERR_UNKNOWN_FILE_EXTENSION is thrown by Node.js's ESM loader when it encounters a file whose extension it does not recognise as a natively executable module format. Node.js natively handles .js, .mjs, .cjs, and (since Node 22 with a flag) .ts. Every other extension — .tsx, .jsx, .json5, .wasm, .coffee, etc. — requires a registered loader or module hook.

The ESM loader is active when a file is treated as an ES module: either the file has a .mjs extension, or the nearest package.json contains "type": "module". In CommonJS mode Node.js uses require() hooks instead — that is why the same ts-node setup that works without "type": "module" breaks immediately after you add it.

Full Error Message

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /project/src/index.ts
    at new NodeError (node:internal/errors:405:5)
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:99:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:142:36)
    at defaultLoad (node:internal/modules/esm/load:91:20)
    at ESMLoader.load (node:internal/modules/esm/loader:407:26)
    at ESMLoader.moduleProvider (node:internal/modules/esm/loader:326:58)
    at new ModuleJob (node:internal/modules/esm/module_job:66:26)
    at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:378:17)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:356:34)
    at async Promise.all (index 0) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

You may also see the same error for other extensions:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".tsx" for /project/src/App.tsx
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".jsx" for /project/src/app.jsx
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".json5" for /project/config/app.json5
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".wasm" for /project/lib/crypto.wasm

All Causes at a Glance

Extension Cause Recommended fix
.ts "type": "module" in package.json; ts-node CJS hook not active under ESM loader tsx, or node --experimental-strip-types (Node 22.6+), or ts-node --esm
.tsx Same as .ts plus JSX transform needed (Node native stripping does not support .tsx) tsx (handles .tsx natively); ts-node with --esm also works
.ts (Node 22.14 regression) Node 22.14 broke native TS stripping (nodejs/node#57756); works in Node 22.13 and 22.15+ Upgrade to Node 22.15+ or use tsx as a workaround
.ts (direct node without any loader) Running node src/index.ts without --experimental-strip-types on Node < 22.6 Add the flag, or use tsx, or compile first with tsc
.json5 No built-in loader for JSON5; Node only parses standard .json Custom --import loader hook using the json5 package
.wasm WebAssembly modules are not loaded by default in ESM mode node --experimental-wasm-modules src/index.mjs (Node 12+)
Any unknown extension Custom in-house extension (e.g. .vue SSR, .graphql as module) Write a custom load() module hook registered with --import

Cause 1 – ts-node with "type": "module" in package.json

This is by far the most common trigger. When "type": "module" is added to package.json — often to enable import/export syntax — Node.js switches to its ESM loader for .js files. The legacy ts-node integration works by registering a require() hook that intercepts .ts files before Node's CJS loader processes them. That hook is never called in ESM mode, so Node falls through to its default handler and throws ERR_UNKNOWN_FILE_EXTENSION.

// package.json — the presence of "type": "module" triggers this error with ts-node
{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "dev": "ts-node src/index.ts"   // ← fails with ERR_UNKNOWN_FILE_EXTENSION
  }
}
// tsconfig.json — even with correct compilerOptions, ts-node's CJS hook won't fire in ESM mode
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "node",
    "target": "ES2020",
    "esModuleInterop": true
  }
}
Key insight: The error is not caused by a TypeScript misconfiguration — it is caused by the module loader switching from CJS to ESM. ts-node's default install only hooks the CJS loader.

Cause 2 – Running node src/index.ts directly on Node < 22.6

Developers sometimes run node src/index.ts expecting Node.js to handle TypeScript. Before Node 22.6, Node has no built-in TypeScript support and immediately rejects the extension. This happens regardless of whether "type": "module" is present.

# Node 20 / Node 18 — no native TypeScript support
$ node src/index.ts
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /project/src/index.ts

Cause 3 – Node 22.14 regression (nodejs/node#57756)

Node 22.14 (released January 2025) introduced a regression in --experimental-strip-types that caused it to emit ERR_UNKNOWN_FILE_EXTENSION for previously-working setups. The bug was tracked in nodejs/node#57756. Node 22.15 restores correct behavior.

# Node 22.14 — broken
$ node --experimental-strip-types src/index.ts
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" ...

# Node 22.15+ — works
$ node --experimental-strip-types src/index.ts
# runs successfully

Cause 4 – .tsx files (JSX + TypeScript)

.tsx files need both TypeScript type stripping and JSX transformation. Node 22+'s native --experimental-strip-types does not support .tsx (only .ts and .mts). ts-node's ESM mode requires additional JSX config. tsx handles .tsx natively without any configuration.

Cause 5 – Unknown extensions: .json5, .wasm, .graphql, etc.

Any file extension Node.js does not recognise in ESM context — .json5, .wasm, .graphql, .yaml, .csv, etc. — will trigger this error when imported. These require a custom module hook.

Fix 1 – Use tsx (recommended for all TypeScript projects)

tsx is a Node.js CLI enhancement that transparently handles .ts and .tsx files. It works with both CJS and ESM projects, requires zero configuration, and is actively maintained to keep up with Node.js releases.

# Install
npm install --save-dev tsx

# Run directly
npx tsx src/index.ts

# With nodemon
npx nodemon --exec "npx tsx" src/index.ts
// package.json scripts
{
  "scripts": {
    "dev": "tsx src/index.ts",
    "dev:watch": "tsx watch src/index.ts"
  }
}
// tsx also supports --import mode for registering it as a loader
// Useful when you still need `node` to be the process entry point (e.g. for debuggers):
node --import tsx/esm src/index.ts    // ESM projects
node --import tsx/cjs src/index.ts    // CJS projects
node --import tsx    src/index.ts     // auto-detects mode
tsx vs ts-node: tsx uses esbuild under the hood, making it roughly 10x faster than ts-node for startup. It does not perform type checking — run tsc --noEmit separately for type safety. ts-node can optionally run the type checker inline but this is significantly slower.

Fix 2 – Node 22.6+ native TypeScript stripping (--experimental-strip-types)

Node 22.6 added experimental native TypeScript support via the Amaro library. It strips type annotations from .ts files without transforming TypeScript-specific syntax. No npm install required.

# Node 22.6 to 23.5 — requires flag
node --experimental-strip-types src/index.ts

# Suppress the experimental warning
node --experimental-strip-types --no-warnings src/index.ts

# Node 23.6+ — enabled by default, no flag needed
node src/index.ts
// package.json — use NODE_OPTIONS so all child processes inherit the flag
{
  "scripts": {
    "dev": "node --experimental-strip-types src/index.ts"
  }
}

// Or set in the environment (CI, Docker):
// NODE_OPTIONS="--experimental-strip-types"
Limitations of --experimental-strip-types:

Fix 3 – ts-node ESM mode (legacy path)

If you need to stay on ts-node, enable its ESM integration. This requires changes to both tsconfig.json and how you invoke the script.

Option A: tsconfig.json + --esm flag

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",   // or "node16" / "nodenext"
    "target": "ES2022",
    "esModuleInterop": true,
    "strict": true
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  }
}
// package.json scripts
{
  "scripts": {
    "dev": "ts-node --esm src/index.ts"
  }
}

Option B: ts-node-esm binary

npx ts-node-esm src/index.ts

Option C: node --loader (deprecated in Node 20, still functional)

# Basic
node --loader ts-node/esm src/index.ts

# Suppress deprecation warning on Node 20+
node --no-warnings=ExperimentalWarning --loader ts-node/esm src/index.ts

# Transpile-only mode (skips type checking, faster startup)
node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only src/index.ts
ts-node ESM compatibility: ts-node v10.9.2 has known issues with Node 20+ ESM. If you encounter persistent failures, use the transpile-only loader variant or switch to tsx.

Fix 4 – Remove "type": "module" (CommonJS fallback)

If your project does not actually need ESM — you added "type": "module" because something said to, but you use require() everywhere — simply remove it. ts-node's default CJS hook will work correctly.

// package.json — before (broken with ts-node)
{
  "type": "module",
  "scripts": {
    "dev": "ts-node src/index.ts"
  }
}

// package.json — after (works)
{
  "scripts": {
    "dev": "ts-node src/index.ts"
  }
}
// tsconfig.json for CommonJS ts-node (no "type": "module" in package.json)
{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "target": "ES2020",
    "esModuleInterop": true,
    "strict": true
  }
}

Fix 5 – Compile TypeScript first, then run JavaScript

The cleanest production approach: compile .ts to .js with tsc, then run the output with node. No loader, no experimental flags, no runtime dependency on ts-node or tsx.

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "nodenext",
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true,
    "strict": true
  },
  "include": ["src"]
}
// package.json
{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"          // tsx for dev, compiled JS for prod
  }
}

Fix 6 – Custom module hook for .json5 and other unknown extensions

For non-TypeScript extensions, register a custom load() hook using the --import flag (Node 20.6+). This replaces the deprecated --loader flag for production use.

// json5-loader.mjs — custom hook for .json5 imports
import { readFileSync } from 'node:fs';
import JSON5 from 'json5';

// Node 20.6+ module hook API
export async function load(url, context, nextLoad) {
  if (url.endsWith('.json5')) {
    const source = readFileSync(new URL(url), 'utf8');
    const parsed = JSON5.parse(source);
    return {
      format: 'json',
      shortCircuit: true,
      source: JSON.stringify(parsed),
    };
  }
  return nextLoad(url, context);
}
# Register the hook before loading the entry point
node --import ./json5-loader.mjs src/index.mjs

# Or set via NODE_OPTIONS for the whole process tree
NODE_OPTIONS="--import ./json5-loader.mjs" node src/index.mjs
// src/index.mjs — importing a .json5 config
import config from '../config/app.json5';   // works with the hook registered
console.log(config.apiUrl);

Fix 7 – WebAssembly modules (.wasm)

# Node 12+ experimental WebAssembly module support
node --experimental-wasm-modules src/index.mjs
// src/index.mjs
import wasmModule from './lib/crypto.wasm';
// wasmModule is a WebAssembly.Module instance
const instance = await WebAssembly.instantiate(wasmModule);
console.log(instance.exports.add(1, 2));  // 3

Safe TypeScript Run Patterns

Choose the right pattern for your use case:

Pattern A — Development with tsx, production with compiled JS

// package.json
{
  "type": "module",
  "scripts": {
    "dev":   "tsx watch src/index.ts",
    "build": "tsc --noEmit && tsc",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}

Pattern B — Node 22+ native TypeScript (zero dependencies)

// package.json
{
  "type": "module",
  "scripts": {
    "dev":   "node --experimental-strip-types --watch src/index.ts",
    "start": "node --experimental-strip-types src/index.ts",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/node": "^22.0.0"
  }
}

Pattern C — ts-node ESM (existing ts-node projects)

// package.json
{
  "type": "module",
  "scripts": {
    "dev": "node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only src/index.ts"
  }
}

// tsconfig.json
{
  "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022" },
  "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" }
}

Pattern D — nodemon + tsx for file-watching dev server

// nodemon.json
{
  "watch": ["src"],
  "ext": "ts,tsx",
  "exec": "tsx src/index.ts"
}
# Or inline (no nodemon.json)
npx nodemon --watch src --ext ts,tsx --exec "tsx src/index.ts"

Pattern E — Docker / CI consistency

# Dockerfile — compile TypeScript, run the output (no runtime loader needed)
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build        # runs tsc → dist/

FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]

Debugging Checklist

  1. Read the error — note the exact file extension and full path.
  2. Check your package.json for "type": "module". If present, ts-node's CJS hook is inactive.
  3. Run node --version. Node 22.6+ supports --experimental-strip-types; Node 23.6+ runs .ts natively.
  4. If on Node 22.14, upgrade to 22.15+ — 22.14 has a known regression in TypeScript stripping.
  5. If using ts-node, verify you are calling it with --esm or via ts-node-esm when "type": "module" is set.
  6. If the extension is .tsx, use tsx — neither ts-node's default ESM mode nor Node's native strip supports JSX.
  7. For non-TypeScript extensions (.json5, .wasm), write a custom --import loader hook.
  8. Check for a NODE_OPTIONS env var that may be adding or missing a required loader flag.
  9. Confirm your tsconfig.json module matches your intended module system: CommonJS for CJS projects, ESNext or NodeNext for ESM.
  10. Run tsc --noEmit separately to ensure type errors are not hiding the real problem.

Node.js Version Compatibility Table

Node.js version Native .ts support Recommended approach
Node 16, 18, 20 None tsx, or ts-node with --esm, or compile with tsc
Node 22.0 – 22.5 None tsx, or ts-node with --esm, or compile with tsc
Node 22.6 – 22.13 --experimental-strip-types (inline types only) node --experimental-strip-types or tsx
Node 22.14 Broken (regression #57756) tsx; avoid native stripping on this release
Node 22.15+ --experimental-strip-types fixed node --experimental-strip-types or tsx
Node 23.0 – 23.5 --experimental-strip-types node --experimental-strip-types or tsx
Node 23.6+ Enabled by default (no flag needed for inline types) node src/index.ts works; use tsx for enums/decorators

Frequently Asked Questions

What is TypeError [ERR_UNKNOWN_FILE_EXTENSION]?

ERR_UNKNOWN_FILE_EXTENSION is thrown by Node.js's ESM loader when it encounters a file extension it cannot execute natively — most commonly .ts when "type": "module" is in package.json. Node natively handles .js, .mjs, .cjs, and .json. All other extensions require a registered loader or module hook to process the file before Node executes it.

Why does ERR_UNKNOWN_FILE_EXTENSION happen with ts-node and "type": "module"?

ts-node's default setup registers a require() hook for the CJS loader. When "type": "module" is present, Node uses its ESM loader instead — the CJS hook is never invoked. The ESM loader sees a .ts extension it does not understand and throws the error. The fix: enable ts-node's ESM mode (--esm), or switch to tsx, or use Node 22.6+ native TypeScript support.

What is the fastest fix for Unknown file extension ".ts"?

In 2026, the fastest fix is replacing ts-node with tsx: run npx tsx src/index.ts with no other changes. Alternatively, on Node 22.6+ add --experimental-strip-types to your node invocation. On Node 23.6+, node src/index.ts works out of the box.

How do I fix ERR_UNKNOWN_FILE_EXTENSION in ts-node without switching tools?

Add "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" } to your tsconfig.json and run with ts-node --esm src/index.ts. Or use the node --loader ts-node/esm invocation. For Node 20+ where ts-node's ESM is known to be unstable, use node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only src/index.ts.

How do I fix ERR_UNKNOWN_FILE_EXTENSION in Node 22?

Use node --experimental-strip-types src/index.ts on Node 22.6+. If you are on Node 22.14, upgrade to 22.15+ — 22.14 has a known regression. This flag strips inline TypeScript type annotations only; it does not support enums, namespaces, or .tsx files. For those, use tsx.

Why does ERR_UNKNOWN_FILE_EXTENSION only appear in production or CI?

Local dev often uses nodemon or ts-node directly, which registers the CJS hook early. CI and production scripts frequently call node src/index.ts directly without any loader. The difference: CJS hook vs. bare Node invocation. Use tsx or Node 22+ --experimental-strip-types for consistent behavior across all environments.

How do I fix ERR_UNKNOWN_FILE_EXTENSION for .json5 files?

Write a custom load() module hook that intercepts .json5 URLs, parses them with the json5 npm package, and returns the result as JSON. Register it with node --import ./json5-loader.mjs src/index.mjs. See the code example in Fix 6 above.

Should I use ts-node or tsx in 2025?

For most projects in 2026, tsx is the better default. It is zero-config, handles both CJS and ESM, works with .tsx files, and is significantly faster at startup. ts-node remains useful for projects that need built-in type checking during development, or that rely on ts-node-specific plugins. On Node 22.6+ you can skip both tools for simple TypeScript files by using --experimental-strip-types.

Does ERR_UNKNOWN_FILE_EXTENSION affect .tsx files?

Yes. .tsx triggers the same error. Node 22+'s --experimental-strip-types does not support .tsx — only .ts and .mts. Use tsx (the npm package) for .tsx files as it handles JSX transformation natively.

What is the --loader flag vs --import for fixing this error?

--loader was the original way to register ESM hooks. It was deprecated in Node 20 in favour of --import with a module that calls register() (Node 20.6+). Both still work, but --loader emits a deprecation warning on Node 20+. For new projects, use --import: node --import tsx src/index.ts. For legacy ts-node setups using --loader ts-node/esm, add --no-warnings=ExperimentalWarning to suppress the warning.

Related Errors