Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Fixing ERR_UNKNOWN_FILE_EXTENSION for .ts Files

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" appears when Node.js is asked to execute a TypeScript file as an ES module and has no way to handle the extension. It is most common after adding "type": "module" to a project that ran scripts through ts-node, or when a script is run with plain node on a runtime that does not strip types. This guide explains why the loader rejects the file and gives the three reliable fixes: native type stripping, a loader such as tsx, or compiling first.

Exact symptoms and error messages

The error is thrown by Node's ESM loader when it resolves a .ts file and finds no registered handler for the extension:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /repo/scripts/release.ts
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:219:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:245:36)
    at defaultLoad (node:internal/modules/esm/load:120:22) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

With ts-node, the same root cause often appears after switching a package to ESM, because ts-node's default require hook does not intercept ESM loading:

$ npx ts-node scripts/release.ts
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /repo/scripts/release.ts

On newer Node.js versions with type stripping, a related error points at TypeScript features that cannot simply be erased:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Root cause analysis

Node's ESM loader decides how to evaluate a file from its extension (and the nearest package.json type field for .js). It knows .js, .mjs, .cjs, .json, .wasm and, in recent releases, .ts, .mts and .cts. On runtimes without TypeScript support, a .ts file has no known format, so the loader throws before reading it. CommonJS was more forgiving: require() consulted require.extensions, which tools like ts-node patched. ESM has no equivalent registry — custom formats must be added through loader hooks registered with --import or module.register(). How the loader picks a format is described in ESM and CJS Interoperability.

How Node's ESM loader treats a .ts file The loader resolves the file, determines its format from the extension, and either strips types natively, delegates to a registered loader hook, or throws. resolve ./release.ts file exists on disk determine format by extension and package type strip types or hook Node 22.18+/23.6+, or tsx via --import else throw ERR_UNKNOWN_FI LE_EXTENSION
Without native type stripping or a registered hook, the format step fails and the loader throws before reading the file.

Node.js added type stripping in stages. It arrived behind --experimental-strip-types in 22.6, became enabled by default in 23.6, and was enabled by default on the 22 LTS line from 22.18. Type stripping replaces type annotations with whitespace and runs the result; it does not type-check, and it only supports syntax that can be erased. Features that generate runtime code — enum, namespace with values, parameter properties in constructors — need --experimental-transform-types or a full transpiler.

Resolution and configuration patch

Choosing how to run TypeScript files Branches for native type stripping on modern Node, a tsx loader for older runtimes or full syntax, and compiling to JavaScript for production. Need to run a .ts file script, dev server or production? Native type stripping node scripts/release.ts Node 22.18+ script tsx loader node --import tsx scripts/release.ts older Node / enums Compile first tsc or tsup, then node dist/ production
Native stripping is simplest for scripts; tsx covers older runtimes and all syntax; production code should run compiled output.

Option 1: native type stripping (Node.js 22.18+ or 23.6+)

On a supported runtime, run the file directly:

node scripts/release.ts

Make the TypeScript compiler agree with what Node.js can run by enabling the options designed for this workflow:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "noEmit": true
  }
}

erasableSyntaxOnly (TypeScript 5.8) turns enums, value namespaces and parameter properties into compile errors, so you find them in the editor instead of at runtime. verbatimModuleSyntax forces import type for type-only imports, which type stripping requires. Relative imports inside these scripts must name the real file: import { bump } from './lib/version.ts'.

Option 2: a loader for older runtimes or full syntax

tsx registers ESM and CommonJS hooks that transpile TypeScript with esbuild on the fly. It supports all syntax, including enums and decorators, and works on Node.js 18 and later:

npm install -D tsx
node --import tsx scripts/release.ts
# or the bundled CLI
npx tsx scripts/release.ts

In package.json scripts:

{
  "scripts": {
    "release": "tsx scripts/release.ts",
    "dev": "tsx watch src/server.ts"
  }
}

If you are on ts-node, its ESM support requires node --loader ts-node/esm, which relies on an older hooks API and prints deprecation warnings on current Node.js. Moving to tsx or native stripping is the lower-maintenance path.

Option 3: compile first for production

Servers, CLIs and published packages should run compiled JavaScript. Type stripping is deliberately disabled for files under node_modules, so a published package that points main or bin at a .ts file fails for every consumer with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Build with tsc or a bundler, and point exports and bin at the .js output:

npx tsc -p tsconfig.build.json
node dist/server.js
Ways to run TypeScript in Node.js Compares native type stripping, tsx and compiling with tsc on supported syntax, type-checking, Node version support and suitability for production. Native stripping tsx loader tsc then node Enums, namespaces errors (strip-only) supported supported Type-checks no no yes Node 18 / early 20 unavailable works works Inside node_modules disabled works, not advised plain JS Startup overhead minimal small transpile cost none at runtime
None of the runtime options type-check; run tsc --noEmit in CI whichever you choose.

Monorepos: scripts, configs and tooling

Monorepos accumulate .ts files that are run directly rather than built: release scripts, code generators, vitest.config.ts, eslint.config.ts. Most tools that load their own config files — Vite, Vitest, ESLint, Playwright — bundle or transpile the config themselves, so ERR_UNKNOWN_FILE_EXTENSION there usually means the tool's version is too old to support a TypeScript config. For your own scripts, pick one runner for the whole repository and invoke it consistently from the root package scripts. Mixing ts-node in one package, tsx in another and native stripping in a third means every contributor has to remember which is which, and CI images need all three to work.

A useful pattern is to declare the runner once as a root devDependency and call it through pnpm exec tsx or an npm script, so every package uses the same version. If your Node.js floor is 22.18 or later, standardise on native stripping with erasableSyntaxOnly and drop the loader entirely.

Worked example: a release script after the switch to ESM

A library repository keeps its release automation in scripts/release.ts, run with ts-node scripts/release.ts from an npm script. The maintainers add "type": "module" to the root package.json so that vite.config.js and the test suite run as ESM. The next release fails with ERR_UNKNOWN_FILE_EXTENSION for scripts/release.ts.

The CI image runs Node.js 22.18, so native type stripping is available. The fix involves three edits. The npm script changes from ts-node scripts/release.ts to node scripts/release.ts. A small scripts/tsconfig.json enables erasableSyntaxOnly, verbatimModuleSyntax and allowImportingTsExtensions, and type-checking it immediately flags one enum ReleaseType that becomes a union of string literals and a const object. Relative imports inside the scripts gain .ts extensions. ts-node is removed from devDependencies, which also removes its transitive dependencies from the lockfile.

A contributor still on Node.js 20 now sees the unknown-extension error locally. Rather than keep two paths, the team sets engines.node to >=22.18 for the repository root and adds an .nvmrc, so version managers select the right runtime automatically. If supporting older local runtimes had mattered more, the script would have used tsx instead — the only change being the command in the npm script.

When the error comes from a dependency

Occasionally the .ts path in the error is inside node_modules. That means a package published TypeScript sources as its entry point — a packaging bug. Runtimes that support type stripping refuse such files deliberately, with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, and older runtimes report the unknown extension. Check whether the package ships a compiled entry under another condition, file an issue with the maintainer, and in the meantime bundle the dependency (bundlers transpile it happily) or pin the last version that published JavaScript.

CLI validation and debug commands

# Does this runtime strip types by default?
node -p "process.features.typescript ?? 'not supported'"

# Run with explicit flags on runtimes where it is still experimental
node --experimental-strip-types scripts/release.ts

# Check that nothing in scripts/ needs a transform
npx tsc -p scripts/tsconfig.json --noEmit

# Confirm the published entry points to JavaScript, not TypeScript
node -p "JSON.stringify(require('./package.json').exports, null, 2)" | grep -n "\.ts\"" || echo "no .ts targets"

process.features.typescript reports "strip" or "transform" on runtimes with built-in support.

Prevention and CI/CD guardrails

  • Standardise on one TypeScript runner for scripts across the repository, pinned as a root dev dependency.
  • Enable erasableSyntaxOnly in any project run through native type stripping, so incompatible syntax fails in the editor.
  • Type-check separately. Add tsc --noEmit to CI; none of the runtime approaches catch type errors.
  • Never publish .ts entry points. Validate the tarball's exports, main and bin targets as part of the release job.

Frequently Asked Questions

Why did this start after adding "type": "module"? Because your scripts were previously loaded through require(), which ts-node patches, and now they are loaded through the ESM loader, which it does not patch by default. Switch to tsx or native type stripping.

Does native type stripping read my tsconfig.json? No. It ignores paths, target and every other compiler option, and simply erases types. Aliases must be expressed with the imports field in package.json instead of paths.

Can I use .ts files in a published package if consumers use Node 23+? No. Type stripping is intentionally disabled inside node_modules to keep published packages compiled and fast to load. Always publish JavaScript plus declaration files.

Why does tsx work but plain node fail in my Docker image? The image is running an older Node.js release than your laptop, one without type stripping enabled by default. Either upgrade the base image to Node.js 22.18 or later, or keep tsx as the runner. Check with node --version inside the container rather than assuming the tag you pulled.

Is it safe to run production servers with a TypeScript loader? It works, but it adds a transpile step to every cold start and ties your runtime to a development tool. Compiling once during the build and shipping plain JavaScript is faster to start, easier to debug with source maps, and removes a dependency from the production image.

Related

ESM and CJS Interoperability