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.
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.
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.
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.
Cross-browser variation
- Chrome / Edge:
@types/chrometracks Chrome’s surface closely but lags new APIs by weeks. Declaring a minimal local.d.tsfor a brand-new API is normal and preferable toany. - Firefox: the polyfill types match Firefox’s native
browser.*closely, including Firefox-only members likebrowser.sidebarAction, which@types/chromewill 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.onMessagetypings 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 returnstrueand always callssendResponse.
Verification
- 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.
- Delete a member from your
Requestunion and confirm the worker’s switch fails to compile — that is the exhaustiveness check earning its place. - Temporarily remove the
hasSidePanelguard 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.
Related
- Using the webextension-polyfill in MV3 — the runtime half of the same decision.
- Feature detection instead of browser sniffing — runtime guards that pair with the type guards here.
- Building a capability matrix for your extension — turning those guards into a single table you maintain.
- Cross-browser API compatibility — the parent reference for engine differences.