Two compiler options decide what syntax your consumers receive and which globals your types assume. Getting them wrong produces two very different complaints — a bundle full of helper functions nobody needs, or an error like this one from a consumer with no browser in sight:

error TS2304: Cannot find name 'HTMLElement'.
  node_modules/@scope/my-library/dist/index.d.ts:14:32

Both are configuration decisions that ship, and both are worth making deliberately rather than inheriting from a starter template.


Root Cause

target controls the JavaScript syntax level of the emitted code. Anything newer than the target is rewritten, and rewrites that cannot be expressed inline require helper functions which TypeScript injects into each emitting file.

lib controls which ambient type declarations are in scope. It affects type-checking directly and the published declarations indirectly: if a public signature references HTMLElement, that name must exist in a consumer’s compilation for your .d.ts to type-check, and it only exists if they included DOM in their own lib.

The mistakes are symmetrical. A target too low ships bloated output to runtimes that did not need it; a lib too wide exports types that assume globals a consumer does not have.


Minimal Reproduction

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["ES2022", "DOM"],
    "declaration": true,
    "outDir": "./dist"
  }
}
{
  "engines": { "node": ">=20.11.0" }
}
// src/index.ts
export async function readAll(items: AsyncIterable<string>): Promise<string[]> {
  const out: string[] = [];
  for await (const item of items) out.push(item);
  return out;
}

export function mount(el: HTMLElement): void {   // browser type in a Node library
  el.dataset.mounted = "true";
}

Two problems ship together: for await is down-levelled into a large helper for a runtime that supports it natively, and HTMLElement appears in the published declarations.


What Each Option Ships

What target and lib each affect target decides the syntax level of emitted JavaScript and whether helper functions are injected. lib decides which ambient globals are assumed, which reaches consumers through the published declaration files. Two options, two entirely different consumer effects "target" affects: emitted JavaScript syntax too low → helpers, larger output too high → parse errors on old runtimes should equal the oldest runtime named in engines "lib" affects: globals your types assume too wide → consumers need DOM types too narrow → your own build fails should match the environments your public API actually touches

Step-by-Step Fix

1. Align target with the engines floor

   "compilerOptions": {
-    "target": "ES2017",
+    "target": "ES2022",

Node 20 parses every ES2022 feature natively, so nothing needs down-levelling. Check the effect immediately:

npx tsc -p tsconfig.build.json && grep -c "__awaiter\|__asyncGenerator\|__spreadArray" dist/*.js || echo "no helpers emitted"

Expected output: no helpers emitted.

2. Narrow lib to what the API actually needs

-    "lib": ["ES2022", "DOM"],
+    "lib": ["ES2022"],

If the build now fails on HTMLElement, that is the point: the reference was in a public signature and needs a decision. Either the function belongs in a browser-specific entry point served under the browser condition, or its parameter should be typed structurally so no DOM global is required:

export function mount(el: { dataset: Record<string, string> }): void {
  el.dataset.mounted = "true";
}

3. If a low target is genuinely required, share the helpers

{
  "compilerOptions": { "target": "ES2017", "importHelpers": true },
  "dependencies": { "tslib": "^2.6.0" }
}
- var __awaiter = (this && this.__awaiter) || function (thisArg, ...) { /* 20 lines */ };
+ import { __awaiter } from "tslib";

One small dependency replaces a copy of each helper in every file. When the target is raised later, remove both the option and the dependency in the same change — a tslib dependency with no helpers to supply is pure footprint.

Three helper strategies A low target inlines helper copies in every emitted file. importHelpers replaces them with imports from a single small dependency. A modern target emits no helpers at all. Helpers are a cost of down-levelling, not of TypeScript low target a helper copy per file grows with module count worst option for a many-module library importHelpers one shared dependency helpers imported, not copied right when a low target is genuinely required modern target no helpers emitted no extra dependency smallest and fastest, where engines allows it

HAZARD PREVENTION

Symptom: Raising target produces a syntax error in a consumer’s build tool rather than at runtime.

Root cause: The consumer’s bundler or test runner parses your published files with an older parser configuration than their runtime supports — an outdated acorn setting, a legacy Babel preset, or a Jest transform that excludes node_modules.

Fix: Treat a target raise as a major release and say so in the changelog, naming the minimum Node version. Consumers with an old toolchain then know why it broke and what to change.


Verification

# target and engines agree
node -p "require('./package.json').engines?.node"
node -p "require('./tsconfig.build.json').compilerOptions.target"

# no browser globals in the published declarations
grep -rnE '\b(HTMLElement|Window|Document|Navigator)\b' dist/*.d.ts && echo "DOM types leaked" || echo "ok: no DOM globals"

# no duplicated helpers
grep -c "__awaiter" dist/*.js 2>/dev/null | grep -v ":0" && echo "helpers inlined" || echo "ok: no inlined helpers"

Expected: an engines floor and target that correspond, ok: no DOM globals, and ok: no inlined helpers.


Edge Cases / Gotchas

  • lib narrower than target is legal and confusing. TypeScript does not add globals implied by the target unless lib lists them, so a package can target ES2022 while type-checking against ES2015 globals and fail on Array.prototype.at.
  • skipLibCheck hides the DOM leak from you. With it on, an inherited browser type inside a dependency’s declarations goes unnoticed until a consumer compiles without it.
  • A browser build needs its own configuration. Sharing one tsconfig.json between a Node entry point and a browser entry point forces lib to be the union of both, which reintroduces the leak.
  • @types/node behaves like a lib entry. Including it makes process, Buffer and node: modules available, and any of those appearing in a public signature obliges consumers to install it too.
  • Down-levelling class fields changes semantics subtly. useDefineForClassFields interacts with target, and flipping the target across the ES2022 boundary can alter initialisation order in classes with declared-but-unassigned fields.

Frequently Asked Questions

Should a library target the oldest runtime it supports?

Yes. Consumers can down-level your output with their own build but cannot up-level it, so targeting the oldest runtime in engines works for everyone.

What does lib actually change in the published package?

It decides which global type declarations your code is checked against and which globals your declarations assume exist — which reaches consumers directly through the .d.ts files.

Why does lowering target increase bundle size?

Down-levelling requires helper functions, and TypeScript inlines a copy into every file that needs one unless importHelpers is enabled.

Is importHelpers with tslib worth the extra dependency?

Yes when the target is low enough to generate helpers; pure overhead when it is not. Remove both together when raising the target.

How do target and engines relate?

They are two halves of one claim and should always agree — engines states which runtimes may install, target states which syntax they will receive.



↑ Back to Optimizing tsconfig.json for Library Distribution