tsc, on-the-fly, and native type stripping compared
Anyone running TypeScript in Node.js projects quickly runs into conflicting advice about tsc, ts-node, ESM, and CommonJS. This article covers the practical options for running TypeScript in Node, clears up the module interop pitfalls between ESM and CommonJS, and gives you a minimal, working tsconfig for CLI and build-script projects, including the native type-stripping support in recent Node versions.
Table of Contents
- 1. Why TypeScript in Node.js causes so many headaches
- 2. Two basic strategies: compile-then-run vs. on-the-fly
- 3. tsc in detail: build step, watch mode, and output directory
- 4. On-the-fly transpilers: ts-node, tsx, and their trade-offs
- 5. ESM vs. CommonJS: understanding the package.json type field
- 6. .cts and .mts: forcing an explicit module type per file
- 7. Configuring moduleResolution NodeNext correctly
- 8. Native type stripping in Node.js 22, 23, and later
- 9. A minimal tsconfig.json for CLI and build-script projects
- 10. Summary
- 11. FAQ
1. Why TypeScript in Node.js causes so many headaches
TypeScript in a browser bundle has long been routine: a bundler like Vite or webpack handles transpilation and module resolution, and developers barely notice it happening. In Node.js projects that intermediary is often missing entirely, especially for CLI tools, build scripts, or small backend services without a framework. That's where you hit the question of how .ts files actually become runnable JavaScript, and the answer has shifted repeatedly over the past few years: tsc alone does not execute code, ts-node was the de facto standard for a long time but is slow and error-prone on the ESM side, and since Node 22 there is suddenly a native path with no extra package at all.
The confusion is compounded by the package.json field type, which retroactively changes how every .js file in a project is interpreted, by moduleResolution settings with cryptic names like NodeNext, and by the fact that many tutorials and Stack Overflow answers date from a time before Node.js had native ESM support at all. Setting up a new Node project with TypeScript today requires less guesswork than it did two years ago, but only if you know the current options instead of following an outdated guide.
2. Two basic strategies: compile-then-run vs. on-the-fly
At its core there are exactly two ways to run TypeScript in Node. The first strategy, compile-then-run, compiles the entire project ahead of time with tsc into plain JavaScript in an output directory like dist/, and Node then only ever runs those finished .js files. This exactly matches production behavior, since Node never sees TypeScript syntax at any point, and tsc reports all type errors before the program ever starts, rather than at runtime.
The second strategy, on-the-fly, transpiles TypeScript at the moment of execution, either through a loader like ts-node and tsx, or now natively through Node itself. The advantage is a shorter feedback loop during development, since there is no separate build step between a code change and a test run. The downside: most on-the-fly tools only strip types syntactically, without performing real type checking, so type errors can slip unnoticed into a test run or even into production unless tsc --noEmit also runs as a separate step in the CI pipeline.
3. tsc in detail: build step, watch mode, and output directory
The tsc compiler reads tsconfig.json, checks types against the configured lib and target settings, and, with the outDir option enabled, writes plain JavaScript into a separate directory. For a CLI or build-script project, a single npm run build call before deployment or before publishing to npm is usually enough. The --watch flag keeps the compiler running in the background and recompiles incrementally on every file change, which makes the build step tolerable during development, even if it doesn't quite match the instant execution of on-the-fly tools.
What matters is a clean separation between the source directory and the output directory via rootDir and outDir, so compiled .js files don't accidentally end up next to the .ts sources and get mistaken for source code by Git or the editor. In practice, a lean pair of scripts in package.json works well: build for a one-off compile run, and start, which runs the compiled file from dist/ with node, exactly as it will later happen in production or in a Docker image.
# Minimal compile-then-run workflow for a Node CLI project
npm install --save-dev typescript @types/node
# One-off build according to tsconfig.json, output to dist/
npx tsc
# Watch mode during development, incremental rebuild
npx tsc --watch
# Production: only run the compiled JavaScript
node dist/index.js
# package.json scripts that mirror exactly this flow
# "scripts": {
# "build": "tsc",
# "start": "node dist/index.js",
# "dev": "tsc --watch"
# }
4. On-the-fly transpilers: ts-node, tsx, and their trade-offs
ts-node was the standard way for years to run TypeScript files directly with node -r ts-node/register or the ts-node binary. By default it runs a full type check on every start, which noticeably costs time on larger projects and slows back down the fast dev loop that on-the-fly transpilation was supposed to buy you in the first place. The --transpile-only mode skips the type check and speeds up startup considerably, but it fully offloads type errors to a separate tsc --noEmit run, one you then have to remember to actually execute.
tsx solves the same problem on top of esbuild and has established itself as the faster, less finicky alternative: it transpiles files in milliseconds, supports ESM and CommonJS in the same project without manual loader configuration, and comes with a built-in watch mode. Like all esbuild-based tools, though, tsx also performs no real type checking, it strips type annotations purely syntactically. The pragmatic combination in many teams: tsx or tsx watch for the daily dev loop, tsc --noEmit as a separate CI step for actual type safety, and tsc with outDir for the final production build.
5. ESM vs. CommonJS: understanding the package.json type field
Node.js supports two module systems side by side: the classic CommonJS with require and module.exports, and the native ECMAScript module system with import and export. Which system applies to a .js file is decided primarily by the type field in the nearest package.json: if it's missing or set to "commonjs", Node interprets every .js file as CommonJS. If it's set to "module", that same file is automatically treated as ESM, with all the consequences that follow: no more require without createRequire, no __dirname and __filename without the detour through import.meta.url, but top-level await without any workaround.
For TypeScript projects this means: tsconfig.json alone does not determine whether ESM or CommonJS code comes out at the end, it has to match the type field in package.json. A common source of errors is a type field set to "module" combined with a tsconfig that still outputs "module": "commonjs", or the other way around: Node then reports SyntaxError: Cannot use import statement or ERR_REQUIRE_ESM at runtime, even though the TypeScript compilation itself completes without errors. An exports field in package.json additionally makes explicit which entry points a package provides for ESM and CommonJS consumers respectively.
{
"name": "cli-tool-example",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"bin": {
"cli-tool-example": "./dist/index.js"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
6. .cts and .mts: forcing an explicit module type per file
Sometimes a single type field isn't enough, for example when a package needs to serve both ESM and CommonJS consumers at the same time, or when individual build scripts inside a larger monorepo must stay CommonJS while the rest has already migrated to ESM. TypeScript has exactly this case covered with the file extensions .mts and .cts: an .mts file is always treated as ESM regardless of the type field and compiles to .mjs, a .cts file is always treated as CommonJS and compiles to .cjs.
This is especially useful for dual-published npm packages that need to be importable from both modern ESM projects and older CommonJS codebases, without maintaining the entire source twice. In practice, explicit extensions remain optional for most CLI or internal build-script projects, since a single consistent module system for the whole project is entirely sufficient there. But as soon as interop boundaries need to be bridged within the same repository, for example a webpack.config.cts next to otherwise pure ESM code, .cts and .mts are the cleanest solution without resorting to a second build target.
// build.mts: always ESM, regardless of the "type" field in package.json
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const configPath = fileURLToPath(new URL("./config.json", import.meta.url));
const raw = await readFile(configPath, "utf-8");
export const config = JSON.parse(raw);
// webpack.config.cts: always CommonJS, even inside an ESM project
import type { Configuration } from "webpack";
const config: Configuration = {
mode: "production",
entry: "./src/index.ts",
};
module.exports = config;
7. Configuring moduleResolution NodeNext correctly
The moduleResolution compiler option determines the rules TypeScript uses to resolve import paths into modules. The legacy "node" mode mirrors the resolution behavior of CommonJS-era Node from before native ESM, and it ignores the fact that modern Node versions apply stricter rules for ESM imports. The recommended mode for new Node projects is "NodeNext" (paired with "module": "NodeNext"): it reads the type field from package.json per file, or the .mts/.cts extension, and applies exactly the rules Node also applies at runtime.
The most noticeable consequence of NodeNext: relative imports require an explicit file extension, and specifically the extension of the output file, not the source file. An import from utils.ts must therefore be written as import { helper } from "./utils.js", even though the source file is named .ts, because Node resolves the compiled .js file at runtime. Anyone who overlooks this rule typically gets the error TS2835, complete with a helpful suggestion from TypeScript itself for the correct extension. This adjustment feels unfamiliar at first, but it prevents exactly the kind of import errors that would otherwise only surface at runtime in Node, not already at compile time.
8. Native type stripping in Node.js 22, 23, and later
Since Node.js 22.6 there has been a native way to run TypeScript files without any additional npm package: the --experimental-strip-types flag. Node removes only the type annotations from the source, without checking them, conceptually similar to what tsx or esbuild already do, just directly inside the Node runtime itself. Only "erasable" syntax constructs are supported: plain type annotations, interfaces, and type aliases work fine, while enum declarations, constructor parameter properties, and namespaces produce additional runtime constructs and are either handled specially or rejected depending on the Node version.
With Node 23.6, support took an important step forward: type stripping is enabled by default there, and .ts files can be started directly with node file.ts, no flag required. The important caveat remains that no real type checking happens at any point, it is still pure syntax removal, not a replacement for tsc --noEmit in the CI pipeline. For small CLI scripts or one-off build helpers that previously carried a ts-node or tsx dependency just for this single purpose, native type stripping in current Node versions is already a serious, dependency-free alternative.
# Node 22.6 through 23.5: flag required to enable type stripping
node --experimental-strip-types src/index.ts
# Node 23.6+: type stripping is enabled by default, no flag needed
node src/index.ts
# Erasable syntax works out of the box (interfaces, type aliases, annotations)
# enum and namespaces may need extra Node flags or must be excluded via
# "erasableSyntaxOnly": true in tsconfig
# Type checking does NOT happen during type stripping, so also run in CI:
npx tsc --noEmit
9. A minimal tsconfig.json for CLI and build-script projects
A lean Node CLI or build-script project doesn't need most of the numerous compiler options familiar from large frontend setups. What matters is a small set of values that are consistent with each other: module and moduleResolution set to "NodeNext" for correct module resolution, target matched to the minimum supported Node version, outDir and rootDir for a clean separation of source and build directories, and strict: true, because turning on strict mode later in a growing project is considerably more painful than starting strict from day one.
It's also worth adding esModuleInterop for smoother handling of CommonJS packages that don't yet offer ESM exports, and skipLibCheck, so type checking doesn't unnecessarily extend into the .d.ts files of third-party packages. The table below compares the execution paths discussed in this article side by side, to make the decision for a concrete project easier.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
| Approach | Production-ready | Typical pitfall | Recommended use |
|---|---|---|---|
| tsc build + node dist/ | Yes, standard path | Rebuild required on every change | CI/CD, Docker images, npm packages |
| ts-node (with type checking) | Not really, too slow | Full type check on every start | Local debugging with type checking |
| tsx / esbuild-based | No, no type checking | Type errors go unnoticed | Fast dev loop, watch mode |
| Native type stripping | Conditionally, more stable since Node 23 | No type checking, enum limited | CLI scripts without a build step |
| CommonJS without type field | Risky with new packages | Silent require/import interop errors | Legacy code only, plan a migration |
The table makes it clear: there is no universally "best" approach, just a clear division of labor. Speed pays off for the daily dev loop, safety pays off for the final build, and both can be combined in a single project without compromise.
Mironsoft
TypeScript setup, build tooling, and Node.js development for Magento and Hyvä projects
Want a TypeScript setup without the headaches?
We set up your Node.js tooling, build scripts, and headless integrations with a clean tsconfig, correct ESM/CommonJS setup, and modern type stripping, so you can focus on your code instead of module errors.
tsconfig audit
Migrating existing Node projects to NodeNext and strict mode
Build tooling
CLI tools, build scripts, and deployment pipelines with TypeScript
Headless integrations
Type-safe connections between Magento APIs and Node.js services
10. Summary
TypeScript with Node.js doesn't have to be a guessing game once you clearly separate the two basic strategies: tsc plus node dist/index.js for production, because it reports type errors before startup and exactly matches the production environment's behavior, and a fast on-the-fly transpiler like tsx or native type stripping for the daily dev loop, combined with a separate tsc --noEmit step for actual type safety. The type field in package.json, moduleResolution: "NodeNext", and, where needed, explicit .mts/.cts extensions resolve the most common ESM/CommonJS interop problems, provided you apply them consistently from the start rather than patching them in later.
Native type stripping, available since Node 22.6 and enabled by default since Node 23.6, now makes many small CLI scripts and build helpers fully dependency-free to run, but it does not replace real type checking in the CI pipeline. Anyone who knows these building blocks and brings them together in a lean, well-commented tsconfig.json avoids the recurring module errors that seemingly every other Node TypeScript project reinvents from scratch.
TypeScript with Node.js, the essentials at a glance
Compile-then-run for production
tsc builds to dist/, node dist/index.js runs exactly that result, type errors stop the build.
On-the-fly for the dev loop
tsx or native type stripping speed up development but don't replace tsc --noEmit.
ESM vs. CommonJS
The type field in package.json controls how every .js file in the project is interpreted.
Native type stripping
Behind a flag since Node 22.6, on by default since Node 23.6, with no real type checking at runtime.