Overview
Node never required a build step — JavaScript just runs — and TypeScript reintroduced
one, which the ecosystem has spent a decade shrinking back toward zero. Unlike the
frontend, where bundling is forced by the browser, a backend service gets to choose its
position on a spectrum: from tsc emitting plain JavaScript, through transpile-only dev
runners, to runtimes that execute TypeScript source directly, to deliberately bundling
anyway for deployment. The genuinely hard part is none of these — it is the ESM/CJS
module split, which remains the sharpest operational edge in the whole ecosystem.
Key points
- (a)
tscas the whole build: emit JS +.d.ts+ source maps todist/, runnode dist/main.js. Boring, dependency-free, and mandatory anyway for published libraries (declarations only come fromtsc). The default until proven insufficient. - (b) Transpile-only runners for dev:
tsx(esbuild-based) andts-node --swcstrip types in memory with watch mode — instant restarts, nodist/during development. Types are checked separately (tsc --noEmitin CI), per the standard check/emit split. - (c) Node's native type stripping:
--experimental-strip-typesgraduated to stable in Node 22.18+ / 23.6+ —node main.tsjust works. The constraint is erasable syntax only: enums, namespaces with runtime meaning and constructor parameter properties are rejected, quietly steering style toward types-as-annotations TypeScript. - (d) Deno and Bun run TypeScript directly and always have — no flag, no step; type checking is a separate explicit command in both.
- (e) Bundle the backend anyway: tsup/esbuild collapse a service and its
dependencies into one file — smaller lambda cold starts,
node_modules-free container images, single-artifact deploys. The frontend's technique, borrowed for ops reasons rather than platform ones. - ESM vs CJS is the central pain:
"type": "module"flips a package's.jsfiles to ESM;exportsmaps declare the public entry points (and wall off deep imports); dual packages ship both formats and risk the two-copies hazard. Interop has softened — modern Node supportsrequire()of ESM — but mismatched default exports and mis-configuredexportsmaps remain the ecosystem's top bug reports. module/moduleResolutionmust match the world the code runs in:nodenextfor anything Node executes (honoursexportsmaps, requires explicit.jsextensions in relative imports),bundlerfor code a bundler will consume. Wrong dial, wrong resolution — most "works locally, breaks published" stories start here.- Monorepo backends: TS project references give per-package incremental builds
with enforced dependency direction (
tsc --buildwalks the graph); orchestrators (Turborepo, Nx) hash each package task's inputs and cache outputs — the same idea as the Gradle build cache, at coarser, package-level granularity instead of per-task file tracking.
Details
The tsconfig dials for a Node service
| Setting | Value | Why |
|---|---|---|
module |
nodenext |
Emit and check against Node's actual ESM/CJS semantics; implies matching moduleResolution |
target |
Match the Node LTS you run | No down-levelling for features the runtime has natively |
strict |
true |
The floor, per the deep dive |
verbatimModuleSyntax |
true |
Forces type-only imports to be marked — exactly what erasable-syntax runtimes need |
declaration + declarationMap |
true for libraries |
.d.ts output; go-to-definition lands in source, not declarations |
outDir / rootDir |
dist / src |
Keep emitted output out of the source tree |
Choosing a spectrum position
- A service in a container: (a) —
tscbuild in the image, run the JS. Add (b) for the local dev loop. - Scripts and small tools: (c) —
node script.tswith no project ceremony; or (d) if Deno/Bun are already in the stack. - Serverless / cold-start-sensitive: (e) — one bundled file, dependencies included, nothing to resolve at boot.
- A published library: always (a) for the emit — clean ESM +
.d.ts— whatever runs in development.
Examples
A minimal Node 22+ ESM service:
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"target": "es2023",
"strict": true,
"verbatimModuleSyntax": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/main.js"
}
}
The dev loop never emits; CI runs typecheck and build; production runs plain
JavaScript.
Related
- The TypeScript build ecosystem — parent summary: the backend as the "increasingly no-build" half of the fault line.
- Package managers: npm, pnpm, yarn, bun —
workspaces and the
exports-map machinery the module split runs through. - The bundler world — the tools behind position (e), bundling the backend on purpose.
- TypeScript deep dive — the language-side view: erased types, strictness dials, and ESM/CJS flagged as the operational edge.
- Gradle deep dive — the input-hashing build cache the monorepo orchestrators borrow, at finer granularity than they attempt.