target and lib Settings for Published Packages
Pick a TypeScript target and lib that match the runtimes you support, avoid shipping helpers nobody needs, and stop browser globals leaking into a Node library's types.
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
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.
HAZARD PREVENTION
Symptom: Raising
targetproduces 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
acornsetting, a legacy Babel preset, or a Jest transform that excludesnode_modules.Fix: Treat a
targetraise 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
libnarrower thantargetis legal and confusing. TypeScript does not add globals implied by the target unlessliblists them, so a package can target ES2022 while type-checking against ES2015 globals and fail onArray.prototype.at.skipLibCheckhides 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.jsonbetween a Node entry point and a browser entry point forceslibto be the union of both, which reintroduces the leak. @types/nodebehaves like alibentry. Including it makesprocess,Bufferandnode:modules available, and any of those appearing in a public signature obliges consumers to install it too.- Down-levelling class fields changes semantics subtly.
useDefineForClassFieldsinteracts withtarget, 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.
Related
- Optimizing tsconfig.json for Library Distribution — the surrounding option set these two belong to.
- Why skipLibCheck Hides Broken Published Types — the setting that conceals the
libleak described here. - Reducing Dependency Weight in Published Packages — where
tslibsits in the footprint picture.