Typing chrome and browser APIs in TypeScript

Set up TypeScript for a cross-browser MV3 extension — choosing between @types/chrome and webextension-polyfill types, typing message payloads, and narrowing Chrome-only APIs.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

TypeScript in an extension goes wrong in a specific way: you install @types/chrome, write browser.storage.local.get(), and the compiler tells you browser does not exist. Install the polyfill types instead and now chrome.sidePanel is missing. The two type packages describe two different namespaces, and getting both into one project requires deciding which one is the source of truth. This guide is part of cross-browser API compatibility.

The two type packages and what each covers

@types/chrome describes Chrome’s chrome.* surface, including Chrome-only APIs, and types most calls as returning void with a callback or a promise depending on the overload. webextension-polyfill’s bundled types describe the standardised browser.* surface, always promise-based, and deliberately omit anything that is not cross-browser.

Which type package gives you whatCoverage of namespaces, promise shape, Chrome-only APIs and manifest types across @types/chrome, webextension-polyfill types, and using both together.Need@types/chromepolyfill typesBoth installedbrowser.* namespaceAbsentTypedTypedchrome.* namespaceTypedAbsentTypedPromise returns everywhereOverloadedAlwaysDepends which you callchrome.sidePanel, chrome.offscreenTypedAbsent by designTypedmanifest.json shapechrome.runtime.ManifestV3Manifest.WebExtensionMani…Pick one
Using both is the usual answer — one as the default namespace, the other reached for deliberately.

Step-by-step

1. Install both, and make one of them the default

1npm install -D typescript @types/chrome
2npm install webextension-polyfill
3npm install -D @types/webextension-polyfill

Execution context: your local shell. webextension-polyfill is a runtime dependency; the other two are build-time only. Recent polyfill releases ship their own declarations, in which case the @types package is unnecessary — check node_modules/webextension-polyfill/out/browser-polyfill.d.ts before adding it.

2. Configure tsconfig for an extension target

 1{
 2  "compilerOptions": {
 3    "target": "ES2022",
 4    "module": "ESNext",
 5    "moduleResolution": "bundler",
 6    "lib": ["ES2022", "DOM", "WebWorker"],  // WebWorker for the service worker globals
 7    "strict": true,
 8    "types": ["chrome"],                     // chrome.* available without an import
 9    "skipLibCheck": true
10  },
11  "include": ["src"]
12}

Execution context: read by tsc and by your bundler. Including both DOM and WebWorker produces a couple of conflicting globals; skipLibCheck suppresses the noise, and in exchange you get self, caches and clients typed in worker files.

3. Use the polyfill import as your default namespace

1import browser from "webextension-polyfill";
2
3export async function readSettings(): Promise<Settings> {
4  const { settings } = await browser.storage.local.get("settings");
5  return { ...DEFAULTS, ...(settings as Partial<Settings>) };
6}

Execution context: any extension context. storage.local.get is typed as returning Record<string, unknown>, which is honest — the store has no schema — so a cast at the boundary is the right place to assert your own shape, once.

4. Type your own message protocol

The APIs are typed; your messages are not. A discriminated union turns every handler into an exhaustive switch the compiler checks.

 1// messages.ts
 2export type Request =
 3  | { type: "settings:read" }
 4  | { type: "settings:write"; patch: Partial<Settings> }
 5  | { type: "articles:list"; site: string; limit?: number };
 6
 7export type Response =
 8  | { type: "settings"; value: Settings }
 9  | { type: "articles"; value: Article[] }
10  | { type: "error"; message: string };
11
12export function send<R extends Response["type"]>(req: Request) {
13  return browser.runtime.sendMessage(req) as Promise<Extract<Response, { type: R }>>;
14}

Execution context: a shared module imported by the worker, the popup and the options page. Keeping the union in one file is what makes a renamed message a compile error instead of a runtime silence — the versioning concern covered in versioning message schemas across updates.

5. Narrow Chrome-only APIs behind a type guard

chrome.sidePanel exists in @types/chrome unconditionally, so the compiler will happily let you call it on a build that runs in Firefox. Make the check part of the type.

 1type SidePanelCapable = typeof chrome & { sidePanel: typeof chrome.sidePanel };
 2
 3function hasSidePanel(api: unknown): api is SidePanelCapable {
 4  return typeof api === "object" && api !== null && "sidePanel" in api;
 5}
 6
 7export async function showPanel(tabId: number) {
 8  if (!hasSidePanel(globalThis.chrome)) return openFallbackTab(tabId);
 9  await globalThis.chrome.sidePanel.open({ tabId });
10}

Execution context: the service worker. The guard costs one in check at runtime and makes the fallback branch mandatory at compile time — the discipline described in feature detection instead of browser sniffing.

6. Type-check the manifest too

 1// manifest.config.ts — consumed by the build step
 2import type { Manifest } from "webextension-polyfill";
 3
 4export const manifest: Manifest.WebExtensionManifest = {
 5  manifest_version: 3,
 6  name: "Reader",
 7  version: "2.4.0",
 8  background: { service_worker: "service-worker.js", type: "module" },
 9  permissions: ["storage", "alarms"],
10};

Execution context: your build script, not the browser. Generating the manifest from a typed object catches a misspelled permission before the store does — see generating a manifest per browser target.

How the types flow through a buildA typed manifest and a typed message union feed the bundler, which emits the worker, content scripts and pages that share one checked protocol.messages.tsdiscriminated unionsettings.tsstorage shapemanifest.config.tsWebExtensionManifesttsc checks all three against every entry pointservice-worker.tsWebWorker libcontent.tsDOM libpopup.tsDOM lib
Everything crossing a context boundary — messages, storage shapes, the manifest — is worth a type; the rest is ordinary application code.

Typing the storage boundary honestly

The compiler cannot know what is in chrome.storage, and pretending otherwise is the most common way a typed extension lies to itself. storage.local.get("settings") is typed as returning { [key: string]: any } — cast it to Settings and every downstream error becomes a runtime surprise on a profile that was written by an older version.

The honest shape is a parse at the boundary, once, with defaults for everything.

 1export interface Settings {
 2  theme: "light" | "dark" | "auto";
 3  syncHour: number;
 4  enabledOrigins: string[];
 5}
 6
 7const DEFAULTS: Settings = { theme: "auto", syncHour: 7, enabledOrigins: [] };
 8
 9function parseSettings(raw: unknown): Settings {
10  const o = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
11  return {
12    theme: o.theme === "light" || o.theme === "dark" ? o.theme : DEFAULTS.theme,
13    syncHour: typeof o.syncHour === "number" && o.syncHour >= 0 && o.syncHour < 24
14      ? o.syncHour : DEFAULTS.syncHour,
15    enabledOrigins: Array.isArray(o.enabledOrigins)
16      ? o.enabledOrigins.filter((x): x is string => typeof x === "string") : [],
17  };
18}

Execution context: a shared module imported by every context that reads settings. This is the same function that handles a migration from an older shape, which is why keeping it separate from the read call is worth the indirection — see defaulting and versioning an options schema.

Two related boundaries deserve the same treatment. Message payloads arrive from another context and are structured-cloned, so a class instance arrives as a plain object with its prototype stripped — a cast to a class type is always wrong. And executeScript results come back as unknown per frame; narrowing them at the call site keeps the injected function’s contract visible.

1const results = await chrome.scripting.executeScript({
2  target: { tabId },
3  func: () => ({ title: document.title }),
4});
5const first: { title: string } | undefined =
6  results[0]?.result as { title: string } | undefined;

Execution context: the service worker. The cast is confined to one line next to the function it describes, which is the difference between a cast that documents an invariant and one that hides a bug.

Where types are guaranteed and where they are assertedFour layers from application code down to the browser, showing that only the top two carry compiler-checked types while storage and message boundaries require parsing.Application codefully checkedthe compiler is authoritative hereParse boundaryparseSettings, message unionunknown → typed, with defaultschrome.storage / messagesstructured clone, no prototypesanything may be missing or staleBrowser runtime@types/chrome describes the API surfacetypes lag new APIs by weeks
Every arrow crossing a process or a persistence boundary needs a parse, not a cast.

Cross-browser variation

  • Chrome / Edge: @types/chrome tracks Chrome’s surface closely but lags new APIs by weeks. Declaring a minimal local .d.ts for a brand-new API is normal and preferable to any.
  • Firefox: the polyfill types match Firefox’s native browser.* closely, including Firefox-only members like browser.sidebarAction, which @types/chrome will never know about.
  • Safari: no dedicated type package exists. Safari’s surface is close enough to the polyfill types that they are the right base; guard the gaps individually.
  • All three: chrome.runtime.onMessage typings do not model the “return true to keep the channel open” contract, so the compiler cannot catch that mistake. Wrap the listener once in a helper that always returns true and always calls sendResponse.

Verification

  1. Run the compiler over the whole source tree and confirm it is clean:
1npx tsc --noEmit

Execution context: your local shell. --noEmit type-checks without writing output, which is what you want when the bundler is doing the emitting.

  1. Delete a member from your Request union and confirm the worker’s switch fails to compile — that is the exhaustiveness check earning its place.
  2. Temporarily remove the hasSidePanel guard and confirm the call still compiles; that is the failure the guard prevents, and seeing it once makes the pattern stick.

FAQ

Can I drop @types/chrome entirely?

Only if you never touch a Chrome-only API. The moment you use chrome.offscreen, chrome.sidePanel or chrome.declarativeNetRequest’s newer members, the polyfill types alone will not describe them.

Should I write chrome or browser in shared code?

Write browser from the polyfill import as the default and reach for chrome only inside a guarded branch. Mixing them freely is what produces code that type-checks and then fails at runtime on the other engine.

How do I type storage.onChanged payloads?

The changes object is Record<string, { oldValue?: unknown; newValue?: unknown }>. Narrow it per key at the point of use rather than casting the whole object — the pattern in storage.onChanged listener patterns.

Other Core APIs & Cross-Browser Data Management Resources