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?
answer
- a support commitment, not a build detail
- the oldest runtime you promise to run on
- a truth claim the checker trusts
- syntax is rewritten, libraries are not
- your declarations bind consumers too
basics
~20 sPick 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.
solid answer
~50 sFor an **application**, `target` is decided by the deployment surface — the Node version you run or the browser baseline you support — and `lib` should describe that same environment plus whatever polyfills you actually load. For a **published library** the calculus differs: your emitted JavaScript runs in *consumers'* environments, so a low `target` is defensive but costs everyone size and speed, while a modern `target` is smaller and faster but excludes older consumers or forces them to transpile your `dist`. The rule I apply is: target the lowest runtime you commit to supporting, and say so in the README. Two things the compiler will not do. It **never polyfills** — downleveled `async`/`await` still needs `Promise`, and no `lib` setting emits an implementation. And your `lib` choice **propagates through your `.d.ts`**: a public API mentioning `AsyncIterable` or `structuredClone` obliges every consumer to load a `lib` that declares it, so a library's `lib` is part of its published contract.
go deeper
Know that target should match the oldest runtime the code must run on and that lib should describe the APIs that runtime really has.
Explain why a low target costs size and speed, why lib must stay truthful, and that polyfills come from a runtime package rather than the compiler.
Own the tradeoff for a real deployment: pick where downleveling happens when a bundler is involved, keep the polyfill set matched to the lib claim, and narrow lib to enforce server/browser boundaries.
Frame target as a published support commitment with a cost borne by consumers, account for the lib requirements your .d.ts imposes on them, and give the decision an owner and a review cadence.
## The question behind the question `target` and `lib` look like build trivia, but they encode a support commitment: which runtimes are allowed to execute this code, and which APIs may that code assume. Two contexts pull in opposite directions. ## Applications: describe the deployment, exactly An application knows where it runs, so both settings are answerable from evidence rather than caution. - **`target` follows the oldest runtime you deploy to.** For a server, that is the minimum supported Node major. For a browser app, it is your support baseline — and if a bundler or Babel step performs the final downleveling anyway, `tsc`'s `target` can be modern and the downlevel decision lives in one place instead of two. Duplicating the decision across two tools is a common source of confusion, so pick where it lives and document it. - **`lib` should describe that same environment plus the polyfills you genuinely load.** Widening `lib` past reality is how compile-clean code reaches a browser that cannot run it. Narrowing `lib` below reality is a legitimate tool: a Node-only package that omits `"DOM"` gets a compile error the moment someone reaches for `document`, instead of a crash in production. The honest framing is that `lib` is a **promise about the runtime**. Keep it true and the checker is your first line of defence; make it aspirational and you have disabled that defence without noticing. ## Libraries: your emit runs in someone else's environment A published package cannot see the runtime, which changes the tradeoff. **Arguments for a low target.** Your `dist` runs anywhere, including consumers who do not transpile `node_modules`. That has historically been the safe default. **Arguments against.** Downleveling is expensive and permanent for your consumers: at ES5 every `async` function becomes a state machine, every class becomes prototype wiring, and iteration goes through helpers. That code is larger, slower, and harder to debug — and modern consumers pay for compatibility they never needed. It also introduces its own runtime dependencies (a `Promise` polyfill for downleveled `async`, `Symbol.iterator` for downleveled iteration), which pushes obligations onto consumers who did not ask for them. **The middle positions.** Publish a modern `target` and state the minimum supported runtime in your README, so the commitment is explicit rather than implied by a config file. Or ship more than one build if the audience genuinely spans both worlds — accepting the maintenance and dual-package complexity that comes with it. Whichever you choose, `target` is a public API decision, not an internal one, and it belongs in a changelog when it moves. ## What the compiler will not do — the two hard limits **It never polyfills.** This is the sentence to lead with. `target` rewrites *syntax*; it does not supply *library code*. Downleveled `async`/`await` calls `Promise` at runtime; downleveled iteration calls `Symbol.iterator`. If those are missing, the code throws. `lib` cannot help — it is type-only and emits nothing. Polyfills are a runtime concern, satisfied by a package such as `core-js` loaded before your code, and their presence is what makes a wide `lib` truthful. **Your `lib` leaks into your published types.** A `.d.ts` that mentions `AsyncIterable<T>`, `ReadableStream` or `structuredClone` requires the *consumer's* `lib` to declare those names. If a consumer compiles with a narrower `lib`, your types fail to resolve in their build even though your JavaScript runs fine. So a library's `lib` is part of its contract in two directions: what it may use internally, and what it obliges consumers to have configured. Keeping the public surface expressed in widely available types — and pushing exotic ones behind narrower internal boundaries — is the discipline that avoids surprising consumers. ## A decision procedure worth stating out loud 1. **Name the runtimes.** Not "old browsers" — a Node major, a browser baseline, with the data behind it. 2. **Set `target` to the lowest of those**, unless a downstream tool owns downleveling, in which case set it modern and say where the decision lives. 3. **Set `lib` to that runtime's real capabilities**, then widen only where a polyfill is actually loaded, and narrow deliberately (dropping `"DOM"` in server code) to enforce boundaries. 4. **For a library, re-check the public `.d.ts`** for types your consumers may not have configured. 5. **Revisit on a schedule.** Baselines age; a `target` chosen for a browser matrix from three years ago is silently taxing every user today. ## What separates a principal answer Not the config values — the framing. Treating `target` as a support commitment with a cost borne by consumers, `lib` as a truth claim that the checker enforces only if you keep it honest, and both as decisions that need an owner, a rationale, and a review date.
- Why can a library's lib choice break a consumer's build even when the library's JavaScript runs fine?Because the published `.d.ts` references global types. If your public API mentions `AsyncIterable` or `structuredClone` and the consumer compiles with a narrower `lib`, their checker cannot resolve those names and their build fails — while the emitted JavaScript, which contains no types at all, executes perfectly.
- When is it right to set lib narrower than what the runtime actually supports?When you want the compiler to enforce a portability boundary. Dropping `"DOM"` from a server-side package turns any use of `document` or `window` into a build error rather than a runtime crash. The same trick keeps code shared between environments inside the intersection of what both provide.
- If a bundler already downlevels the output, what target should tsc use?A modern one, so the downlevel decision lives in exactly one place. Having both tools downlevel means two baselines that can drift apart, and TypeScript's ES5 emit then gets re-processed for no benefit. Whichever tool owns it, document that ownership so the next engineer does not change the wrong file.
- How do you decide it is time to raise a published library's target?Tie it to the runtimes you still commit to supporting, usually the maintenance status of the oldest Node major or the browser baseline in your policy. Treat the raise as a breaking-ish change: announce it, put it in the changelog, and pair it with the size and performance evidence that justifies dropping the old floor.
saying these in an interview costs you the question
- Picks ES5 by default without naming a supported runtime
- Says a wide lib setting proves the APIs exist
- Treats target as an internal build detail for a library
- Ignores that published .d.ts files impose lib requirements
- Downlevels in both tsc and the bundler without noticing