rgoussu@goussu: ~/library/frontend/build-ecosystem
~/library/frontend/build-ecosystem cat backend-builds.md

Building TypeScript for the backend

# The spectrum from tsc-as-the-whole-build to type-stripping runtimes to bundled lambdas, with ESM vs CJS as the central pain and orchestrators caching monorepo task graphs.

Conceptsaved 2026-08-10 #typescript#javascript#build#tooling#node#backend

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) tsc as the whole build: emit JS + .d.ts + source maps to dist/, run node dist/main.js. Boring, dependency-free, and mandatory anyway for published libraries (declarations only come from tsc). The default until proven insufficient.
  • (b) Transpile-only runners for dev: tsx (esbuild-based) and ts-node --swc strip types in memory with watch mode — instant restarts, no dist/ during development. Types are checked separately (tsc --noEmit in CI), per the standard check/emit split.
  • (c) Node's native type stripping: --experimental-strip-types graduated to stable in Node 22.18+ / 23.6+node main.ts just 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 .js files to ESM; exports maps 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 supports require() of ESM — but mismatched default exports and mis-configured exports maps remain the ecosystem's top bug reports.
  • module/moduleResolution must match the world the code runs in: nodenext for anything Node executes (honours exports maps, requires explicit .js extensions in relative imports), bundler for 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 --build walks 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) — tsc build in the image, run the JS. Add (b) for the local dev loop.
  • Scripts and small tools: (c) — node script.ts with 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