skip to content

In a tsconfig.json, what does the `target` compiler option control and what does the `module` option control, and why can you not use one in place of the other?

level: juniorimportance: must knowfreq 66%

answer

  1. two axes, not one
  2. syntax level versus module format
  3. async and ?. rewriting is one of them
  4. require versus import is the other
  5. nodenext decides per file

basics

~20 s

In tsconfig, target sets the language level of the emitted JavaScript syntax, deciding whether things like async/await or optional chaining get rewritten. module sets the output module format, such as CommonJS require and exports or untouched ESM. The two are independent.

solid answer

~40 s

`target` controls the *syntax level* of the JavaScript the compiler writes out. At `"target": "es5"` the compiler rewrites classes into functions, `async`/`await` into a generator-style state machine, and `?.` into explicit checks; at `"target": "es2022"` most of that syntax is emitted as written. `module` controls only how `import`/`export` statements come out: `commonjs` turns them into `require()` calls and assignments on `exports`, `esnext` leaves ESM syntax alone, `preserve` keeps what you wrote, and `node16`/`nodenext` decide per file from the nearest package.json `type` field and the file extension. The two axes are orthogonal — ES2022 syntax inside CommonJS modules is a perfectly normal build. And neither one adds polyfills: downleveled syntax still calls whatever built-ins the runtime actually provides.

code

json · 6 lines
json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "commonjs"
  }
}

go deeper

for a junior

Be able to say in one breath that target is about syntax level and module is about import/export format, and give one example of each rewrite.

for a middle

Explain the concrete rewrites a lower target triggers — class downleveling, async state machines, optional chaining — and that module commonjs turns imports into require calls while esnext leaves them alone.

for a senior

Show that the two are orthogonal and that neither polyfills anything, then connect target to its side effects: it seeds the default lib and the default of useDefineForClassFields.

for a principal

Own the choice for the whole repo: which target the deployed runtimes justify, whether output should be CommonJS, ESM or per-file under nodenext, and how that interacts with what you publish to consumers.

## Two independent axes of emit When `tsc` writes a `.js` file it makes two separate decisions, and TypeScript exposes one compiler option for each. 1. **How new may the syntax be?** That is `target`. Values are language levels: `es5`, `es2015`, `es2017`, `es2020`, `es2022`, `esnext`, and so on. 2. **What shape do the imports and exports take?** That is `module`. Values are module systems: `commonjs`, `es2015`/`es2020`/`es2022`/`esnext`, `node16`, `nodenext`, `preserve`, plus the legacy `amd`, `umd`, `system`. Because they are separate options, every combination is buildable. A Node service commonly uses a modern `target` with `"module": "commonjs"`; a browser library often pairs a conservative `target` with `"module": "esnext"` so a bundler can tree-shake the output. ## What target actually rewrites `target` is a *downlevel* instruction: emit syntax no newer than this level. Concretely, lowering the target turns on rewrites such as: - classes into constructor functions plus prototype assignments (below `es2015`), with an `__extends` helper for `extends`; - `async`/`await` into a state machine driven by `__awaiter`/`__generator` helpers (below `es2017`); - `?.` and `??` into explicit conditional expressions (below `es2020`); - template literals, `let`/`const`, arrow functions, and default/rest parameters into their ES5 equivalents. `target` also has two side effects worth knowing: it supplies the **default `lib`** (the set of built-in type declarations the checker loads when you do not set `lib` yourself), and it decides the **default of `useDefineForClassFields`**. ## What module actually rewrites `module` touches only the module syntax. Given `import { readFile } from "fs"`, `"module": "commonjs"` emits roughly `const fs_1 = require("fs")` and rewrites the call site to `fs_1.readFile`, while `"module": "esnext"` emits the import statement unchanged. Exports mirror that: assignments onto `exports` versus untouched `export` declarations. `node16` and `nodenext` are different in kind: instead of one format for the whole program, they mirror Node's own rule and pick the format **per file** — a `.mts` file is ESM, a `.cts` file is CommonJS, and a plain `.ts` file follows the `"type"` field of the nearest package.json. That is why a single project under `nodenext` can emit both formats. ```json { "compilerOptions": { "target": "es2022", "module": "commonjs" } } ``` That config emits classes, `async`/`await` and `?.` verbatim, wrapped in `require`/`exports` — proof that the two options do not constrain each other. ## Why one cannot substitute for the other Candidates sometimes assume `"module": "esnext"` means "modern output". It does not: with `"target": "es5"` and `"module": "esnext"` you get ES5 function-based classes exported with ESM `export` statements. Conversely raising `target` never converts `require()` back into `import`, because `target` has no opinion about module format at all. ## Neither option adds a polyfill This is the trap that costs production incidents. Downleveling rewrites *syntax*; it never supplies *runtime library code*. With `"target": "es5"`, an `async` function is rewritten into a state machine that still calls `Promise` at runtime — if the engine has no `Promise`, the code throws. TypeScript will tell you the type is missing (the checker says `Promise` requires a newer `lib`), but if you widen `lib` without loading a polyfill you have only silenced the compiler. Polyfills come from a runtime package such as `core-js`, loaded before your code runs. ## What an interviewer is listening for A crisp two-axis answer — syntax level versus module format — plus the observation that they are orthogonal and that neither one polyfills anything. Mentioning that `target` seeds the default `lib`, and that `node16`/`nodenext` decide format per file, is the extra half-step that separates a rehearsed answer from an understood one.

  • If target is es5 but module is esnext, what does the emitted file look like?
    ES5 syntax wrapped in ESM statements: classes become constructor functions with prototype assignments, `async` functions become `__awaiter`/`__generator` state machines, `?.` becomes conditional checks — but the `import` and `export` declarations at the top and bottom are emitted exactly as written, because `module` alone decides module format.
  • Does raising target ever change whether an import stays an import statement?
    No. `target` only bounds the syntax level; module format is decided solely by `module`. With `"module": "commonjs"` your imports become `require()` calls no matter how modern the target is, and with `"module": "esnext"` they stay ESM even at `"target": "es5"`.
  • Under module nodenext, what decides the emit format of one particular file?
    The file extension first — `.mts` emits ESM, `.cts` emits CommonJS. For a plain `.ts` file, the `"type"` field of the nearest package.json decides: `"type": "module"` makes it ESM, anything else (or an absent field) makes it CommonJS. So one program can emit both formats.

saying these in an interview costs you the question

  • Thinks target es5 automatically adds a Promise polyfill
  • Believes module esnext controls syntax downleveling
  • Says raising target converts require() calls back into import
  • Assumes nodenext picks one format for the whole project
  • Treats target and module as two names for the same setting

context