skip to content

target, module & lib

You will learn how target picks the JavaScript language level of the emitted code, how module picks the output module format, and how lib decides which built-in APIs the type checker believes exist. Interviewers ask because targeting ES5 while assuming Promise or Array.flat exists is a classic self-inflicted runtime error.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

6

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

open as a page

A tsconfig sets `"target": "es5"` and `"lib": ["ES2020", "DOM"]`. A call to `[1, [2]].flat()` type-checks cleanly but throws `TypeError: ...flat is not a function` in an old browser. Explain what target and lib each do, and how you would fix this.

level: middleimportance: must knowfreq 72%

basics

~20 s

target controls emitted syntax; lib only tells the checker which built-in APIs it should believe exist. TypeScript never emits polyfills, so declaring ES2020 libs while running on an older engine type-checks calls like Array.prototype.flat that then crash at runtime.

open as a page

What does the TypeScript compiler option `useDefineForClassFields` change about how class fields are emitted, what is its default, and what kind of working code breaks when it becomes true?

level: middleimportance: should knowfreq 34%

basics

~20 s

useDefineForClassFields switches class field emit from plain assignment to Object.defineProperty semantics, matching the ECMAScript standard. It defaults to true when target is ES2022 or higher. Fields declared without an initializer then overwrite inherited values with undefined, and they shadow base-class accessors instead of calling them.

open as a page

With `"target": "es5"` in tsconfig, TypeScript accepts `for (const x of myArray)` but rejects `for (const [k, v] of myMap)` and points you at the `downlevelIteration` option. Why the difference, and what does enabling that flag change in the emitted code?

level: middleimportance: should knowfreq 38%

basics

~20 s

At an ES5 target TypeScript compiles for...of over arrays and strings into a plain index loop, which cannot work for a Map. downlevelIteration makes the compiler emit helpers that call Symbol.iterator instead, so any iterable works — but the runtime must actually provide Symbol.iterator.

open as a page

A TypeScript build that downlevels to ES5 emits the same helper functions — `__extends`, `__awaiter`, `__spreadArray` — at the top of many output files. What does the `importHelpers` compiler option change, what must the package declare, and when is it the wrong choice?

level: seniorimportance: should knowfreq 26%

basics

~20 s

By default the compiler inlines its downleveling helpers into every file that needs them. importHelpers makes it import them from the tslib package instead, so they exist once. tslib then becomes a real runtime dependency, which a published library must list under dependencies.

open as a page

You own the tsconfig for a package published to npm and for the application that consumes it. How do you decide `target` and `lib` for each, and what does the TypeScript compiler explicitly not do for you?

level: principalimportance: should knowfreq 38%

basics

~20 s

Pick target from the oldest runtime that must execute the emitted code, and lib from the APIs that runtime actually provides plus any polyfills you ship. The compiler downlevels syntax only: it never polyfills built-ins, and the lib you choose leaks into your published types as a requirement on consumers.

open as a page