Capítulo 36 de 36

Chapter 36: Choosing Compiler Options (Practical Module Setup Guide)

Core Idea

One tsconfig.json models exactly one runtime environment — pick module settings based on who the actual consumer of the output is (a bundler, Node.js directly, a browser with no build step, or unknown downstream consumers of a published library), never by habit or copy-paste.

Key Concepts

  • One environment per tsconfig: an app mixing server code, browser/DOM code, and test code should split each into its own tsconfig.json (linked via project references) rather than trying to satisfy every environment's globals and module behavior from one config.
  • Bundler-consumed apps (webpack, esbuild, Vite, etc.): the settings cluster is module: esnext + moduleResolution: bundler + esModuleInterop: true, typically with noEmit/emitDeclarationOnly (the bundler does the real transpiling), allowImportingTsExtensions, and verbatimModuleSyntax/isolatedModules for safety with single-file-transpiling tools. Avoid "type": "module" in package.json or .mts files in bundler-only projects for now — some bundlers vary their ESM/CJS interop behavior specifically in that configuration in ways moduleResolution: bundler can't currently model.
  • Compiling and running output directly in Node.js: module: nodenext is the one required setting — it implies moduleResolution: nodenext, esModuleInterop: true, and target: esnext automatically. Remember to actually set "type": "module" in package.json (or use .mts files) if ESM output is intended — nodenext supports emitting either format per-file, it doesn't force ESM by itself.
  • ts-node: aims for compatibility with the same settings as "compile and run in Node.js directly" — treat it the same way.
  • tsx: behaves more like a bundler than ts-node (permits extensionless/index specifiers, freely mixes ESM/CJS) — use the bundler settings cluster for it, not the Node.js cluster.
  • Browser ESM with no bundler at all: TypeScript has no dedicated mode for this, but module: nodenext (for its stricter, extension-enforcing resolution) combined with paths can approximate it — mapping bare/URL specifiers to local .d.ts files for type information, optionally paired with a real browser <script type="importmap"> so the runtime resolution (URL/import-map based) and TypeScript's type resolution (via paths) point at compatible targets even though they work completely differently.
  • Writing a library — the fundamentally different problem: an app author knows their runtime; a library author doesn't know every consumer's settings, and can't practically test against all of them. The practical strategy is to compile against the strictest applicable settings, since satisfying the strict case tends to satisfy the more permissive ones too, not the reverse.
  • Recommended library settings cluster and why each matters:
    • module: node18 (implying moduleResolution: node16) — Node.js-compatible code tends to also work in bundlers; the reverse isn't guaranteed (a bundler-only extensionless export * from "./utils" compiles cleanly under moduleResolution: bundler but crashes under real Node.js ESM resolution, which requires the explicit extension).
    • target: the lowest ECMAScript version actually supported — controls both which syntax gets downleveled and (via the implied lib) which globals the compiler assumes are available, preventing accidental use of runtime features the library's stated minimum doesn't actually have.
    • strict: true — code that only type-errors when strict is disabled is rare, but code that type-checks fine unstrict and then breaks for a strict-enabled consumer (e.g. a widening extends relationship only invalid under strictNullChecks) is a real, easy-to-hit trap for library authors who don't compile strict themselves.
    • verbatimModuleSyntax: true — protects against two consumer-facing pitfalls: (1) import statements whose meaning is ambiguous depending on the consumer's esModuleInterop/allowSyntheticDefaultImports settings (neither value of those flags alone guarantees portability — only writing import syntax unambiguous regardless of them does), and (2) accidentally using export default in a module that will emit as CommonJS, which forces bundler users and Node ESM users to consume the module two different ways.
    • declaration: true — without emitted .d.ts files, consumers get no type information at all.
    • sourceMap/declarationMap: true (optional trade-off) — lets consumers debug into and "go to definition" through to the library's real TypeScript source, at the cost of shipping the maps (and, for declaration maps to be useful, the source files themselves).
    • Separate rootDir/outDir — not just good practice but necessary if the package also ships its .ts sources: without it, extension substitution (TypeScript's own "prefer .ts over .js for the same path" rule) would make consumers' compilers load the library's raw .ts source instead of its .d.ts declarations, causing type errors and needless re-checking.
  • Bundling a library yourself: module: esnext + moduleResolution: bundler becomes acceptable, but with two caveats:
    1. TypeScript can't model "some imports get bundled, some stay external" as one compilation — if your bundle inlines first-party code but leaves external dependency imports real, there's no single moduleResolution setting that's simultaneously correct for both halves; either bundler (may leave unsafe externalized imports unchecked) or nodenext (may over-restrict imports the bundler would actually handle fine) is a compromise, not a perfect model.
    2. Declaration files must be bundled too, or kept consistent with the setting used to check them — an unbundled declaration file preserving an extensionless import (erased fine in the bundled JS, but left as-is in the .d.ts) can produce an invalid, extension-missing specifier for Node.js consumers, silently degrading affected imports to any.
  • Dual-emit (shipping both CJS and ESM builds) is inherently only partially checked: a single TypeScript compilation assumes one output format per input file — if a separate build step produces two output sets (CJS and ESM) from the same type-checking pass, at most one of those outputs actually reflects what was type-checked, since a dependency can expose genuinely different APIs to its CJS vs. ESM consumers. There's no configuration that eliminates this risk entirely; testing and static analysis (e.g. @arethetypeswrong/cli) against all published output bundles before release is the practical mitigation.

Code Examples

// Node.js app/library, compiling and running (or publishing) real output:
{ "compilerOptions": { "module": "nodenext", "verbatimModuleSyntax": true } }
// implies moduleResolution: nodenext, esModuleInterop: true, target: esnext
// Bundler-consumed app (webpack/esbuild/Vite):
{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "noEmit": true,
    "verbatimModuleSyntax": true
  }
}
  • What it demonstrates: the two most common settings clusters — Node.js-targeting vs. bundler-targeting — differing specifically in module/moduleResolution and emit strategy while sharing the same interop/safety flags.

Reference Tables

ScenariomodulemoduleResolutionKey extras
Bundler-consumed appesnextbundlernoEmit, esModuleInterop, verbatimModuleSyntax
Compiling/running in Node.jsnodenext(implied) nodenextset package.json "type" deliberately
ts-nodesame as "running in Node.js"
tsxsame as "bundler-consumed app"
Browser ESM, no bundlernodenext(implied) nodenextpaths mapping URLs/bare specifiers to local .d.ts
Published library (via tsc)node18(implied) node16strict, verbatimModuleSyntax, declaration, split rootDir/outDir
Published library (bundled)esnextbundlerensure declaration files are bundled/consistent too

Key Takeaways

  1. Choose module/moduleResolution based on who actually consumes the output — never copy a config from an unrelated project type.
  2. Library authors should target the strictest plausible settings (node18/node16 resolution, strict: true), since code correct under strict settings tends to stay correct for more permissive consumers, but not vice versa.
  3. A library that ships both CJS and ESM builds from one type-checking pass has, at best, only one of those outputs genuinely type-checked — mitigate with post-build static analysis across every published artifact, not configuration alone.
  4. Always give a published library a separate outDir from its rootDir — otherwise TypeScript's own extension-substitution rule can make consumers load raw .ts source instead of .d.ts declarations.

Connects To

  • Module Theory: the host-modeling framework this practical guide applies.
  • Module Resolution Reference: mechanical detail behind moduleResolution values referenced here.
  • ESM/CJS Interop: the esModuleInterop/verbatimModuleSyntax background this guide's recommendations build on.