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.
answer
- one setting emits, the other only checks
- a promise about the runtime
- no polyfills are ever emitted
- method call needs no downleveling
- narrow it to the truth
basics
~20 starget 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.
solid answer
~50 s`target` decides the syntax level of the emitted JavaScript. `lib` is a completely separate, *type-only* setting: it selects which built-in declaration files the checker loads, so it decides whether `Promise`, `Array.prototype.flat` or `document` are known names. Nothing in `lib` reaches the output — the compiler emits no polyfills at all. Here the build claims an ES2020 runtime while the actual browser is ES5-era, so `flat()` passes the checker and blows up at run time. Three honest fixes: narrow `lib` to what the runtime really provides so the checker rejects the call; or load a polyfill such as `core-js` at startup and keep the wide `lib` as a deliberate promise that the polyfill is present; or raise both `target` and `lib` because you have dropped the old browser. Setting one without the other is what produced the bug.
code
json · 6 lines{
"compilerOptions": {
"target": "es5",
"lib": ["ES5", "DOM"]
}
}go deeper
Remember the one-line split: target changes the syntax that is emitted, lib only changes what the type checker believes exists. Neither adds a polyfill.
Explain why a method call like flat() is never downleveled, and name the three real fixes: narrow lib, load a polyfill, or raise both settings together.
Treat lib as a contract about the deployed runtime and show how you keep it honest — polyfill set matched to the lib claim, and a build that fails rather than a browser that throws.
Own the policy: which runtimes the org supports, whether polyfilling or raising the baseline is cheaper, and how lib is narrowed per package to enforce server/browser boundaries.
## The two things that look like one `target` and `lib` are frequently confused because `target` supplies `lib`'s default. When you write only `"target": "es5"`, the compiler loads the ES5 built-in declarations (plus DOM ones) for you. The moment you write an explicit `lib` array, that default is **replaced**, not extended — and the two settings can now disagree with each other, which is exactly the situation in the question. - **`target` is about emit.** It bounds the syntax the compiler is allowed to write: classes, `async`/`await`, `?.`, `??`, spread, and so on get rewritten down to the chosen level. - **`lib` is about the type checker only.** It selects `lib.*.d.ts` files that declare global types and built-in methods. Not one byte of `lib` is emitted. So `lib` is best read as a **promise you make to the compiler** about the environment your code will run in. If the promise is false, the compiler cannot help you — it believed you. ## Why the call compiles `Array.prototype.flat` is declared in `lib.es2019.array.d.ts`, which `"ES2020"` pulls in transitively. The checker therefore sees `flat` on the array type and accepts the call. `target: es5` had nothing to say about it: `flat()` is a *method call*, ordinary ES5-legal syntax, so there is nothing to downlevel. TypeScript emits `[1, [2]].flat()` verbatim, and an engine without that method throws a `TypeError`. The same trap has a more famous form. With `target: es5` and no `lib`, writing `new Promise(...)` produces a compiler error telling you `Promise` requires a newer `lib`. The tempting fix is to add `"lib": ["ES2015"]` — the error disappears, and the code still throws `Promise is not defined` in the old browser, because widening `lib` silenced the diagnostic without changing the runtime. ## Syntax versus library, the general rule Drawing the line explicitly is what interviewers are checking: - **Syntax** — `class`, `async`/`await`, `?.`, `??`, template literals, destructuring. Handled by `target`, which rewrites them. - **Library** — `Promise`, `Map`, `Set`, `Symbol`, `Array.prototype.flat`, `Object.entries`, `String.prototype.replaceAll`, `structuredClone`. Handled by `lib` at the type level and by a **polyfill** at runtime. `tsc` supplies neither. One wrinkle where the two meet: some downleveled syntax *depends on* library values. An `async` function compiled for ES5 emits a state machine that calls `Promise` at runtime, and downleveled generators/iteration rely on `Symbol.iterator`. So even a purely syntactic feature can require a polyfill once you downlevel it. ## The three fixes, and what each costs 1. **Narrow `lib` to the truth.** Set `"lib": ["ES5", "DOM"]` and the checker rejects `flat()` at compile time — the failure moves from a user's browser to your build. This is the cheapest fix and the one that keeps working as the codebase grows. 2. **Polyfill, then keep the wide `lib`.** Import `core-js` (or a targeted polyfill) at the entry point and the wide `lib` becomes an accurate statement about the shipped environment. The cost is bundle size and the discipline of keeping the polyfill set in sync with the `lib` claim. 3. **Raise `target` and `lib` together.** Correct when you no longer support the old engine. It also shrinks and speeds up the output, since no downleveling is needed. What is *not* a fix: raising `target` alone. Bumping to `"target": "es2020"` changes which syntax is downleveled and shifts the default `lib`, but it still emits no `flat` implementation — a browser without it still throws. ## A related habit worth stating Because an explicit `lib` replaces the default rather than adding to it, `"lib": ["ES2020"]` on its own also removes the DOM declarations, and `document` becomes an unknown name. In a Node-only package that is a feature, not a bug: dropping `"DOM"` makes the checker reject browser globals that would crash on the server. Deliberately choosing a *narrower* `lib` than the runtime provides is a legitimate way to enforce a portability boundary.
- If lib is type-only, why does raising target sometimes make a compile error disappear on its own?Because `target` supplies the *default* `lib` when you have not set `lib` explicitly. Raising `"target": "es5"` to `"es2015"` loads the ES2015 declarations, so `Promise` becomes a known name. The runtime is unaffected — you have changed what the checker believes, not what the engine can do.
- How does an async function at target es5 still work in a browser that has no Promise?It does not. The downleveled state machine emitted by the compiler still constructs a `Promise` at runtime, so the code throws unless a Promise polyfill is loaded first. This is the clearest case of downleveled syntax depending on runtime library support.
- Why would a team deliberately set lib narrower than what their runtime actually supports?To enforce a portability boundary. A Node-only package that omits `"DOM"` gets a compile error the moment someone touches `document` or `window`, instead of a crash on the server. The same trick keeps shared code from reaching for APIs the oldest supported target lacks.
- Does adding an entry to lib ever change the emitted JavaScript?No. `lib` selects declaration files consumed by the checker; they are erased along with all other type information. The only way a `lib` change alters behaviour is indirectly, by allowing or rejecting code you then write.
saying these in an interview costs you the question
- Says lib injects polyfills into the emitted bundle
- Thinks target es5 rewrites flat() into a loop
- Believes an explicit lib array extends the target default
- Assumes a clean type-check proves the API exists at runtime
- Fixes a missing-Promise error by widening lib alone