Hermes Wiki
Developer/Languages/TypeScript/StandardLibraryAndRuntime/Fundamentals/lib-target-and-the-dom-es-node-type-triad

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:

  • target controls syntax transformation — whether tsc downlevels newer syntax (optional chaining, class fields, async/await) into older-runtime-compatible output. This is a real code transformation with a real emit difference.
  • lib controls what the type checker believes exists at runtime — purely a type-checking-time fiction with zero emit effect. Setting lib to include ES2023 tells the compiler Array.prototype.toSorted type-checks; it does not make toSorted actually exist in a runtime that doesn't implement it. lib never 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 lib explicitly to the actual deployment runtime for application code (a specific Node LTS version's ES support, no DOM, for a backend service; DOM plus whatever ES level the target browser matrix actually ships, for frontend code) — this is where lib earns its keep as a real correctness check rather than a formality.
  • Let lib default from target for 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' own lib settings catch misuse.
  • Bump target for syntax-downleveling reasons (dropping support for an old runtime, wanting smaller output by allowing more native syntax) independently from bumping lib for API-surface reasons — treat the coupling as a starting default to override, not a rule to preserve.
  • Don't reach for a broad lib as a way to "make a type error go away" — a missing-API error from lib is 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 widening lib until 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

Hermes Wiki