lib, target, and the DOM/ES/Node Ambient Type Triad
Concept
TypeScript's global (ambient) type declarations — Array.prototype.flat, Promise, document, fetch, Node's process/Buffer — don't come from your source code at all; they're pulled in from a set of built-in .d.ts files selected by the "lib" compiler option. When "lib" isn't set explicitly, it defaults to a set derived from "target": an older target (e.g. ES2018) pulls in an older, smaller set of ES-library globals, a newer target (e.g. ES2022) pulls in more. This coupling is a convenience default, not a hard rule — "lib" can be set independently of "target" — but it means changing target silently changes which APIs type-check as available, which is easy to miss.
Two things run on genuinely separate tracks here, and conflating them is the source of most confusion:
targetcontrols syntax transformation — whethertscdownlevels newer syntax (optional chaining, class fields,async/await) into older-runtime-compatible output. This is a real code transformation with a real emit difference.libcontrols what the type checker believes exists at runtime — purely a type-checking-time fiction with zero emit effect. Settinglibto includeES2023tells the compilerArray.prototype.toSortedtype-checks; it does not maketoSortedactually exist in a runtime that doesn't implement it.libnever emits a polyfill — TypeScript has no runtime code generation for missing APIs at all.
For a Node backend project, there's a third component beyond built-in ES/DOM libs: @types/node, a separately-versioned DefinitelyTyped package providing ambient declarations for Node's own globals (process, Buffer, require, __dirname) and built-in modules (fs, http). A common default-tsconfig mistake is including "DOM" in lib for a pure backend project (or omitting @types/node from lib-adjacent config for a frontend one) — Node has no window/document, and a browser bundle has no process/Buffer unless a bundler shims them, so the "available globals" the type checker believes in should match the actual runtime, not the default that happens to ship with create-* scaffolding.
Tradeoffs
| Configuration approach | Benefit | Cost |
|---|---|---|
Let lib default from target (no explicit lib) |
One less setting to maintain; keeps target/lib in sync automatically as recommended |
Silently changes available-API surface any time target is bumped for an unrelated (syntax-downleveling) reason; easy to not notice a new global just became type-checkable |
Explicit, pinned lib matching the real deployment runtime (e.g. ["ES2022"] for Node 22+, no DOM) |
Type checker's belief about available APIs matches reality exactly; catches "used a DOM global in backend code" or "used an ES2023 method the deployed Node doesn't have yet" at compile time | One more setting that must be manually bumped when the deployment runtime is upgraded, or it under-reports what's actually available |
Broad lib (["ESNext", "DOM"]) regardless of actual runtime |
Never blocks on a missing-type error; convenient for shared code that might run in either environment | Defeats the entire point of lib as a runtime-accuracy check — code that uses document in a file that only ever runs in Node will type-check cleanly and fail at runtime |
Runtime polyfill libraries (core-js) alongside a broad lib |
Makes the broad-lib type declarations actually true at runtime for genuinely-missing APIs |
Bundle-size cost of shipping the polyfill; still requires manually keeping the polyfilled feature set and the lib-declared feature set in sync — TypeScript doesn't know or care that a polyfill exists |
When to use / when not to
- Pin
libexplicitly to the actual deployment runtime for application code (a specific Node LTS version's ES support, noDOM, for a backend service;DOMplus whatever ES level the target browser matrix actually ships, for frontend code) — this is wherelibearns its keep as a real correctness check rather than a formality. - Let
libdefault fromtargetfor library code with no fixed runtime — a published package genuinely might run in either Node or a browser, and forcing callers into one assumption is itself a design mistake; document the supported environment instead and let consumers' ownlibsettings catch misuse. - Bump
targetfor syntax-downleveling reasons (dropping support for an old runtime, wanting smaller output by allowing more native syntax) independently from bumpinglibfor API-surface reasons — treat the coupling as a starting default to override, not a rule to preserve. - Don't reach for a broad
libas a way to "make a type error go away" — a missing-API error fromlibis almost always telling you the code assumes an environment it won't actually run in; the fix is either narrowing the assumption or adding a real polyfill, not wideninglibuntil the checker stops complaining.
Common pitfall
Treating a lib-driven type-check pass as proof an API is safe to use. lib is type information only — if the deployed runtime is actually older than the pinned lib claims (a Node version downgrade in production that didn't get reflected in tsconfig.json, or a browser support matrix that's broader than assumed), code that type-checks cleanly will still throw TypeError: ... is not a function at runtime, because nothing about lib or target verified the claim against reality — that verification has to come from actually testing against the real deployment target, or from a runtime-feature-detection layer like core-js's polyfill approach, which TypeScript's own toolchain doesn't provide.
Engineering Lens
lib/target is a small config surface with an outsized failure mode: because it's usually set once at project scaffolding and rarely revisited, it tends to drift quietly out of sync with the actual deployment runtime as that runtime gets upgraded over a project's lifetime, and the failure only surfaces as a runtime TypeError in production rather than as a compile-time error anywhere — the opposite of what a type system is supposed to guarantee. Treating lib as a runtime-accuracy contract that needs to be revisited on every runtime upgrade (not just at project setup) is a small, cheap habit with a real payoff: it turns a class of "we type-checked cleanly but it crashed in prod" incidents back into a compile-time catch, which is the whole reason to have a type checker in the first place.
Sources
- TypeScript: TSConfig Option — lib
- Understanding lib and target in TypeScript Configuration — Total TypeScript