tsc exits cleanly, the pull request merges, and a few days later your error tracker starts collecting is not a function. In February 2024, a developer using next-intl described what that looks like in issue #884: people on slightly older browsers were triggering t.replace(...).replaceAll is not a function in Sentry, and "My users are hitting error boundary and cannot use the web at the moment." String.prototype.replaceAll is declared in TypeScript's ES2021 lib, so a type check has no reason to object. The fix, PR #885, switched those calls to replace. In May 2024, a Mermaid issue showed the same pattern with structuredClone and Object.hasOwn: on an iPhone running Safari 14, Mermaid 10.9.0 failed with "Can't find variable: structuredClone", while 10.4.0 loaded.
Types are also getting wider on purpose, and Philip Walton's modern-web-types now declares web APIs that have shipped in at least one stable browser engine. His post keeps browser support as a separate check: "I believe the best way to do that is to have tests that run in all the browsers you support. And to catch potential issues earlier in the feedback loop, you can also configure linter plugins like eslint-plugin-baseline-js based on your site’s Baseline target."
This post covers that earlier signal, written as a setup you can copy. By the end you will have:
- your normal
tsconfig.json, with the wide DOM types for editing, - a second
tsconfig.baseline.jsonthat type-checks JavaScript built-ins against a Baseline target, using typescript-baseline-lib, - an ESLint config that checks DOM and built-in usage against the same target, using eslint-plugin-baseline-js,
- two npm scripts for CI, and a pattern for code that is feature-detected or polyfilled.
Everything below was run on October 11, 2026. All output is copied from the terminal.
Who this is for
If you have never hit a missing DOM type, you probably don't need modern-web-types, and you may not need this either. If your team already enforces a Browserslist target with eslint-plugin-compat and is happy with it, keep that. This setup is for projects that turned on wide DOM types and now want a CI step that says "this call is outside our target" before it reaches review.
Versions
| Package | Version |
|---|---|
| typescript | 6.0.3 (tsc results repeated on 7.0.2) |
modern-web-types (installed as @typescript/lib-dom) |
0.6.0 |
| typescript-baseline-lib | 0.1.1 (Baseline snapshot 2026-10-02) |
| eslint | 10.12.0 |
| eslint-plugin-baseline-js | 0.7.5 |
| typescript-eslint | 8.71.1 |
I stayed on TypeScript 6.0.3 for the whole project because typescript-eslint 8.71.1 lists TypeScript <6.1.0 as its peer range, and the lint step needs type information. I ran the same tsc commands on TypeScript 7.0.2 and got the same diagnostics.
npm install --save-dev --save-exact typescript@6.0.3 typescript-baseline-lib@0.1.1 \
"@typescript/lib-dom@npm:modern-web-types@0.6.0" eslint@10.12.0 \
eslint-plugin-baseline-js@0.7.5 typescript-eslint@8.71.1
The sample code
Two files. The first uses JavaScript built-ins that are Baseline Newly available but not yet Widely available:
// src/builtins.ts
const current = new Set(["a", "b", "c"]);
const removed = new Set(["b"]);
export const kept = current.difference(removed); // set-methods: Newly available 2024-06-11
export const answer = Promise.try(() => 42); // promise-try: Newly available 2025-01-07
The second uses element-scoped view transitions, the API behind the first type error Walton shows in his post:
// src/dom.ts
export function animateCard(el: HTMLElement) {
return el.startViewTransition(() => el.classList.toggle("open"));
}
Step 1: wide DOM types for development
This is the setup from the modern-web-types README. Keep lib as you had it and turn on libReplacement, so TypeScript picks up @typescript/lib-dom from node_modules instead of its bundled DOM file.
// tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"lib": ["ESNext", "DOM"],
"libReplacement": true,
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
For comparison, here is a tsconfig.control.json that extends this file and only sets "libReplacement": false, so TypeScript uses its stock DOM lib:
$ npx tsc -p tsconfig.control.json
src/dom.ts(2,13): error TS2339: Property 'startViewTransition' does not exist on type 'HTMLElement'.
With libReplacement: true, npx tsc -p tsconfig.json exits with no output. Set#difference and Promise.try also pass here, because ESNext declares them. That is correct behavior. lib describes a language version, so this config tells you the code is valid TypeScript, and nothing about which browsers can run it.
Step 2: a Baseline tsconfig for JavaScript built-ins
typescript-baseline-lib is a replacement for TypeScript's standard JavaScript libs. Its declarations are generated from web-features data, so a built-in that is outside the target has no declaration. It is a full replacement, which means it runs with noLib. I put it in a second config so the editor keeps using the main one:
// tsconfig.baseline.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": null,
"noLib": true,
"libReplacement": false,
"noEmit": true,
"skipLibCheck": false,
"types": ["typescript-baseline-lib", "@typescript/lib-dom"]
},
"include": ["src/**/*.ts"]
}
Two details matter here:
-
"lib": nullclears the inheritedlibarray, andnoLibstops TypeScript from loading its ownlib.*.d.tsfiles. -
@typescript/lib-domis listed intypes. WithnoLibon, no DOM lib loads by default, so you addmodern-web-typesby name.
Run it:
$ npx tsc -p tsconfig.baseline.json
src/builtins.ts(3,29): error TS2550: Property 'difference' does not exist on type 'Set<string>'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2025' or later.
src/builtins.ts(5,31): error TS2550: Property 'try' does not exist on type 'PromiseConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2025' or later.
Don't follow the suggestion at the end of those messages. It comes from TypeScript's generic TS2550 text, and changing lib would turn the check off.
To confirm what the program contains:
$ npx tsc -p tsconfig.baseline.json --listFilesOnly
/path/to/project/src/builtins.ts
/path/to/project/src/dom.ts
/path/to/project/node_modules/typescript-baseline-lib/baseline.d.ts
/path/to/project/node_modules/typescript-baseline-lib/index.d.ts
/path/to/project/node_modules/@typescript/lib-dom/lib.dom.d.ts
Your sources, the two Baseline files, and the DOM file. No lib.es*.d.ts. With skipLibCheck: false, the full modern-web-types DOM declarations type-checked against the Baseline built-ins without a single declaration error.
This config is responsible for JavaScript built-ins. DOM APIs come from modern-web-types in both configs, so el.startViewTransition() type-checks here too. DOM policy is the next step's job. If you would rather enforce DOM support in the type system itself, uhyo's @baseline-types/dom-* packages provide a DOM lib cut to a Baseline target. That is the opposite choice from modern-web-types, so pick one per config.
Step 3: type-aware lint for the support policy
Types answer "is this declared?". The linter answers "is this call inside our target?" for each expression in your code. I use eslint-plugin-baseline-js for this step. Flat config:
// eslint.config.mjs
import tseslint from "typescript-eslint";
import baselineJs from "eslint-plugin-baseline-js";
export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { project: "./tsconfig.json", tsconfigRootDir: import.meta.dirname },
},
},
{ plugins: { "baseline-js": baselineJs } },
baselineJs.configs["recommended-ts"]({ available: "widely", level: "error" }),
];
Two choices in this file:
- The
recommended-tspreset uses type information, which is how a call likecurrent.difference(removed)gets resolved toSetrather than to an unknown object. -
parserOptions.projectpoints at the maintsconfig.json, the one withmodern-web-types. The rule resolves members through the types you load, so the wide DOM lib is what lets it identifyel.startViewTransition()as theview-transitions-element-scopedfeature.
Output:
$ npx eslint src
/path/to/project/src/builtins.ts
3:29 error 'difference' on Set is not Baseline Widely available (set-methods) baseline-js/use-baseline
5:31 error 'try' on Promise is not Baseline Widely available (promise-try) baseline-js/use-baseline
/path/to/project/src/dom.ts
2:13 error 'startViewTransition' on Element is not Baseline Widely available (view-transitions-element-scoped) baseline-js/use-baseline
✖ 3 problems (3 errors, 0 warnings)
I expected wide types and a strict policy to pull against each other. In practice they help each other: the types give the linter something concrete to identify, and the linter gives the wide types a policy to sit under.
Other tools can fill this step. eslint-plugin-compat checks against a Browserslist config and supports a polyfills setting. @koddsson/eslint-plugin-tscompat is type-aware and also reads Browserslist. If Babel, PostCSS, or your bundler already read Browserslist, its Baseline queries (baseline widely available, baseline 2024) give every tool the same policy name. Each tool ships its own copy of the data, so check that the versions line up.
Step 4: wire it into CI
{
"scripts": {
"check:baseline": "tsc -p tsconfig.baseline.json",
"lint": "eslint ."
}
}
Run both on pull requests. The baseline-checks-demo repository has an example PR that replaces a manual Set filter with Set.prototype.difference(). Both checks fail on that one-line change, with the same TS2550 and set-methods messages as above.
Step 5: code that is guarded or polyfilled
Sooner or later you will want to use something outside the target on purpose. There are two common cases.
Feature detection
Both checks report the call itself, with or without an if around it. I think that is the right default: an exception to the support policy should be visible in the diff, with a reason a reviewer can read.
This is the pattern I use. It passes both the Baseline tsconfig and ESLint:
type MaybeSetMethods<T> = Set<T> & {
difference?: (other: ReadonlySet<T>) => Set<T>;
};
export function diff<T>(a: Set<T>, b: Set<T>): Set<T> {
const s = a as MaybeSetMethods<T>;
// eslint-disable-next-line baseline-js/use-baseline -- feature-detected, fallback below
if (typeof s.difference === "function") return s.difference(b);
return new Set([...a].filter((x) => !b.has(x)));
}
- The intersection type declares
differenceas optional, so TypeScript forces thetypeofcheck before the call. - The disable comment carries a reason, so a reviewer sees why the exception is safe.
- When
set-methodsbecomes Widely available (30 months after June 11, 2024, which is December 2026), you can delete the type, the comment, and the fallback.
Polyfills
If you load a polyfill, tell both checks about it:
import "core-js/proposals/promise-try";
export const answer = Promise.try(() => 42);
// tsconfig.baseline.json
"types": [
"typescript-baseline-lib",
"typescript-baseline-lib/allow/promise-try",
"@typescript/lib-dom"
]
// eslint.config.mjs, scoped to the files that import the polyfill
{
files: ["src/polyfilled/**/*.ts"],
rules: {
"baseline-js/use-baseline": ["error", {
available: "widely",
ignoreFeatures: ["promise-try"],
includeWebApis: { preset: "type-aware" },
includeJsBuiltins: { preset: "type-aware" },
}],
},
}
With those two changes Promise.try passed both checks and Set#difference was still reported. The allow/promise-try entry only adds declarations. It does not load any code, so the import line is what actually puts Promise.try in the browser.
Who is responsible for what
| Concern | Handled by |
|---|---|
| Which DOM APIs exist (including single-engine ones) |
modern-web-types in tsconfig.json
|
| Which JavaScript built-ins are inside the Baseline target |
typescript-baseline-lib in tsconfig.baseline.json
|
| Whether each DOM and built-in call is inside the target | type-aware ESLint (recommended-ts) |
| Intentional exceptions | optional-member types, eslint-disable with a reason, allow/*, ignoreFeatures
|
| Whether the guard and fallback actually work | tests in the browsers you support |
The last row is the one I would keep if I had to drop everything else. Walton puts cross-browser tests first, and I agree. Static checks give you an early signal. Running the code in a browser that lacks the feature is what tells you the fallback works.
Picking the target
I used Baseline Widely available because it is a reasonable default. web.dev's How to choose your Baseline target explains how to check a target against your own analytics. Baseline is defined by a set of core browsers, so look at your traffic for anything outside that set. It also does not track assistive technology support, which Adrian Roselli covers in Baseline Does Not Really Cover Baseline Support.
Moving targets move. In the 2026-10-02 snapshot, Object.groupBy and Promise.withResolvers became Widely available in September 2026, while Set methods have not yet. Pin your package versions so a data update shows up as a dependency bump you can review.
Wrapping up
The setup comes down to one extra tsconfig, one ESLint config, and two scripts. modern-web-types keeps doing its job of describing what browsers ship. The Baseline tsconfig and the linter hold the support policy. Feature detection and real browser tests handle whatever falls outside it.
If you try this on a real codebase, I'd like to hear what the reports look like.
Disclosure: I maintain eslint-plugin-baseline-js and typescript-baseline-lib.
Top comments (1)
Good write-up, and thanks for the disclosure line. On the polyfill case: the allow/* entry in the types array applies to the whole tsconfig, so a file that forgets the import would still type-check. You scoped the ESLint override to a directory, which is the safer pattern. Is there a clean way to get the same scoping on the tsc side, or do you just split it into another tsconfig?