DEV Community

Cover image for Your Build Target Is Not an API Contract: Enforcing Baseline with TypeScript
Leo
Leo

Posted on

Your Build Target Is Not an API Contract: Enforcing Baseline with TypeScript

Vite 7 and later use Baseline Widely Available as the default production build target.

Now put this in a Vite application:

Promise.withResolvers<number>();

document.startViewTransition(() => {
  // Update the DOM.
});
Enter fullscreen mode Exit fullscreen mode

Then run:

npx vite build
Enter fullscreen mode Exit fullscreen mode

The build succeeds.

Regular TypeScript accepts the same code when the standard ESNext and DOM libraries are loaded:

npx tsc --noEmit --target ESNext --lib ESNext,DOM

# exit code 0
Enter fullscreen mode Exit fullscreen mode

At the time of testing, however, neither API was Baseline Widely Available:

That result is not a bug in Vite or TypeScript.

It exposes a gap between two different contracts:

Layer What it controls What it does not control
Vite build target Which JavaScript syntax must be transformed for the target browsers Whether every runtime API you call is available
TypeScript lib Which global APIs exist in the type environment Whether those APIs meet your Baseline policy
Baseline Cross-browser availability of web platform features Your build output or runtime polyfills

A build target is not an API contract.

The 30-second version

The result is an ordinary tsc error. No additional source-code parser is involved.

Why the build passes

Vite describes its TypeScript support as transpile-only. It removes TypeScript syntax but does not run type checking by itself.

Its build target tells the transformer which JavaScript syntax needs to be lowered. It does not automatically reject every API that falls outside the corresponding Baseline target, and it does not generally inject API polyfills.

TypeScript checks APIs through declaration files such as lib.esnext.d.ts and lib.dom.d.ts. Those files model a selected ECMAScript or web platform surface. They are not filtered by Baseline status.

Both tools are doing their intended jobs. The missing layer is a declaration set that represents the API policy we want to enforce.

Add a Baseline API check

Install TypeScript and the two generated declaration packages:

npm install --save-dev \
  typescript@^7 \
  typescript-baseline-lib \
  @baseline-types/dom-widely-available
Enter fullscreen mode Exit fullscreen mode

Commit the lockfile. The package versions determine the exact Baseline snapshots evaluated in CI.

Keep your existing build configuration and add a dedicated TypeScript project.

tsconfig.baseline.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "noLib": true,
    "allowImportingTsExtensions": true,
    "types": [
      "typescript-baseline-lib",
      "@baseline-types/dom-widely-available"
    ]
  },
  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "**/vite-env.d.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The important options are:

{
  "compilerOptions": {
    "noLib": true,
    "types": [
      "typescript-baseline-lib",
      "@baseline-types/dom-widely-available"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

noLib removes TypeScript's standard ES and DOM libraries. The types entries replace them with the Baseline-oriented declarations.

Do not combine this configuration with the standard es* libraries. Doing so would reintroduce the APIs the gate is intended to remove.

I also recommend leaving skipLibCheck disabled. Declaration conflicts are useful signals here, not noise to suppress.

Why this config does not extend the build config

Many frontend projects already declare compilerOptions.lib.

Combining an inherited lib with noLib: true produces a configuration error. A standalone file is therefore the safest copy-paste starting point.

For a larger codebase, extract options such as strict, jsx, paths, and module resolution into a shared config that contains neither lib nor types. The normal build and Baseline check can then extend that shared configuration independently.

If the project contains TSX, add src/**/*.tsx to include and copy its existing jsx option into the Baseline config.

Vite client declaration note

A typical Vite project contains:

/// <reference types="vite/client" />
Enter fullscreen mode Exit fullscreen mode

Vite's client declarations include asset modules and environment types, but they can also reference Worker APIs. Worker declarations are outside the scope of the DOM package used here.

For a strict browser-only gate, exclude the existing vite-env.d.ts and declare only the asset types your application uses:

// src/baseline-env.d.ts
declare module "*.css";

declare module "*.svg" {
  const url: string;
  export default url;
}

declare module "*.png" {
  const url: string;
  export default url;
}
Enter fullscreen mode Exit fullscreen mode

If your application uses import.meta.env, add only the required ImportMetaEnv declarations to the same file.

This avoids widening the Baseline type surface with unrelated Worker globals.


See what the gate catches

Add a mix of established and newer APIs:

const newestOdd = [2, 5, 8, 11].findLast(
  (value) => value % 2 === 1,
);

const response = await fetch("https://example.com");

const observer = new ResizeObserver(() => {});

Promise.withResolvers<number>();
Array.fromAsync([1, 2, 3]);

new EyeDropper();

document.startViewTransition(() => {});

console.log(newestOdd, response.status, observer);

export {};
Enter fullscreen mode Exit fullscreen mode

Run the dedicated check:

npx tsc -p tsconfig.baseline.json
Enter fullscreen mode Exit fullscreen mode

The established APIs compile, while the APIs outside Widely Available are rejected:

error TS2550: Property 'withResolvers' does not exist on type 'PromiseConstructor'.

error TS2550: Property 'fromAsync' does not exist on type 'ArrayConstructor'.

error TS2304: Cannot find name 'EyeDropper'.

error TS2339: Property 'startViewTransition' does not exist on type 'Document'.
Enter fullscreen mode Exit fullscreen mode

At the time of testing:

API Baseline status Result
Array.prototype.findLast() Widely Available Accepted
fetch() Widely Available Accepted
ResizeObserver Widely Available Accepted
Promise.withResolvers() 2024 Newly Available Rejected
Array.fromAsync() 2024 Newly Available Rejected
EyeDropper Limited availability Rejected
Document.startViewTransition() 2025 Newly Available Rejected

Baseline status changes over time. These results were verified on July 23, 2026.

Turn it into a CI contract

Add a separate package script:

{
  "scripts": {
    "check:baseline": "tsc -p tsconfig.baseline.json"
  }
}
Enter fullscreen mode Exit fullscreen mode

If your existing CI job already installs dependencies, adding this command may be enough:

npm run check:baseline
Enter fullscreen mode Exit fullscreen mode

A standalone GitHub Actions workflow looks like this:

# .github/workflows/baseline-types.yml
name: Baseline API check

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  baseline:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - run: npm ci
      - run: npm run check:baseline
Enter fullscreen mode Exit fullscreen mode

For an existing project, introduce this as a separate check first. Review the initial findings, decide how each API should be handled, and only then make the check required.

That avoids turning a useful policy migration into an unexplained wall of CI failures.

Rolling target or fixed year?

A moving target is appropriate for applications that want to adopt APIs as they become Widely Available:

{
  "compilerOptions": {
    "types": [
      "typescript-baseline-lib",
      "@baseline-types/dom-widely-available"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The declaration set advances when the underlying compatibility data and packages are updated.

A fixed Baseline year is more suitable for a library contract, a long-lived release branch, or a product that updates its browser policy deliberately.

Install the matching DOM target:

npm install --save-dev @baseline-types/dom-2024
Enter fullscreen mode Exit fullscreen mode

Then use the cumulative 2024 targets:

{
  "compilerOptions": {
    "noLib": true,
    "types": [
      "typescript-baseline-lib/year/2024",
      "@baseline-types/dom-2024"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

A year target contains APIs that became Baseline Newly Available by the end of that year. It is not the same as saying every API was already Widely Available during that year.

Do not combine a fixed year/* entry with the rolling root package.

Make polyfills explicit

Sometimes an API is outside the selected Baseline target, but your runtime intentionally provides it.

For example, core-js can provide Promise.withResolvers:

npm install core-js
Enter fullscreen mode Exit fullscreen mode

Load the runtime implementation:

import "core-js/proposals/promise-with-resolvers";
Enter fullscreen mode Exit fullscreen mode

Then add the matching audited declaration entry:

{
  "compilerOptions": {
    "noLib": true,
    "types": [
      "typescript-baseline-lib",
      "typescript-baseline-lib/allow/promise-withresolvers",
      "@baseline-types/dom-widely-available"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This permits only that API. The rest of the Baseline boundary remains unchanged.

The allow/* entry changes type availability only. It does not install or load a runtime polyfill.

That separation is intentional: the runtime implementation and the type-level exception should both be visible in review.

What this check does not guarantee

This is an API-surface gate, not a complete browser compatibility system.

It does not guarantee:

  • That an API works without browser-specific bugs.
  • That requirements such as HTTPS, permissions, or user activation are met.
  • That your actual audience matches the Baseline browser population.
  • That JavaScript syntax, CSS, or HTML features meet the same target.
  • That dynamically accessed or deliberately untyped APIs will be detected.

The DOM declarations also preserve some supporting types to keep references valid. They should not be interpreted as a perfect rejection of every non-Baseline DOM symbol.

If your users include old embedded WebViews, managed enterprise browsers, or unusually long-lived devices, Baseline Widely Available may still be too broad. Browser analytics and runtime testing remain necessary.

For broader JavaScript checks, I also use eslint-plugin-baseline-js. It covers syntax and Web API cases that cannot be represented by a TypeScript declaration file.

Scope the check carefully in monorepos

noLib: true replaces the complete global TypeScript library.

That is useful for browser code, but it should not automatically be applied to every package in a monorepo.

For example, current Node.js declarations may depend on standard-library APIs that are newer than your selected Baseline target. A universal configuration combining browser, Node.js, test-runner, and framework globals can therefore produce dependency-level errors unrelated to your browser contract.

Run the gate over the source that actually shares the browser compatibility policy:

{
  "include": [
    "packages/browser/src/**/*.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Keep separate build configurations for Node-only entry points, tooling, tests, and browser applications.

The boundary should follow the deployment environment, not the repository root.

Why generated declaration files?

Manually maintaining a list of APIs would become stale almost immediately.

typescript-baseline-lib generates its JavaScript declarations from the web-features compatibility dataset and TypeScript's own library declarations. The repository checks in its dataset snapshot, classification reports, declaration inventory, generated output, and audit results.

@baseline-types applies Baseline filtering to the generator used for TypeScript's DOM declarations.

The two projects deliberately cover different surfaces:

Package Surface
typescript-baseline-lib JavaScript built-ins such as Promise, Array, Map, and Intl
@baseline-types/dom-* DOM and web platform APIs such as Document, Window, and ResizeObserver

The package lock then makes a given CI run reproducible.

Where this is going

typescript-baseline-lib is currently a public alpha. I recommend adopting it as an additional CI gate before making it the only TypeScript configuration for a production application.

The longer-term goal is first-class TypeScript support:

{
  "compilerOptions": {
    "lib": [
      "baseline"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The corresponding TypeScript proposal is currently awaiting more real-world feedback.

If you try the package, the most useful feedback is concrete:

  • Which project and TypeScript version did you test?
  • Which API did the gate catch?
  • Was the result expected?
  • Did a framework or ambient type package introduce a conflict?
  • Could the check remain enabled in CI?

Try it on one browser-only package:

npm install --save-dev \
  typescript@^7 \
  typescript-baseline-lib \
  @baseline-types/dom-widely-available

npx tsc -p tsconfig.baseline.json
Enter fullscreen mode Exit fullscreen mode

Browser compatibility policies should not depend on every reviewer remembering the support status of every API.

A small, reproducible CI check is a better contract.

Top comments (0)