38.6 TypeScript in Browser Extensions
Right, so you want to build a browser extension. You’ve chosen TypeScript, which means you’re already smarter than 90% of the people who’ve tried this. You’re also in for a special kind of pain, because the development environment for extensions is a bizarre, anachronistic throwback that the web largely left behind in 2010. It’s not TypeScript’s fault; it’s just that extension APIs were designed for JavaScript the way it was then, and we have to make our modern, type-safe code fit into that old jacket. It’s a tight squeeze, but we can make it work and look good.
The core challenge is that your extension isn’t a single application. It’s a collection of loosely coupled, contextually isolated scripts that all hate each other. You’ve got your content scripts that run in the context of a web page, your background/service worker (the extension’s brain), your popup/options/sidebar pages (which are basically tiny web pages), and sometimes even a devtools panel. They all need to talk to each other, and they all need to be written in TypeScript. Let’s break this beautiful mess down.
The Foundation: tsconfig.json and Module Madness
First, the most important decision: you are almost certainly not using ES modules (import/export) in the traditional sense for the core extension parts. Why? Because extension scripts are loaded by the browser via manifest.json, not by each other. Your background script is a single file. Your content script is a single file. The browser doesn’t know how to resolve your node_modules for import _ from "lodash".
Your tsconfig.json is your first line of defense. You’ll likely need two: one for your core scripts (using "module": "esnext" for bundling) and one for your popup/options pages if they’re modern apps (using "module": "esnext"). But for the core, we use a different strategy: bundling.
// tsconfig.extension.json
{
"compilerOptions": {
"target": "es2020",
"module": "esnext", // We bundle, so we use ESNext
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/background.ts", "src/content-script.ts"]
}
We use a bundler like esbuild or Rollup to take our beautifully modular TypeScript code and smash it into a single, browser-ready .js file for each script. This lets us use npm packages and proper imports during development.
# Example esbuild command for a background script
esbuild src/background.ts --bundle --outfile=dist/background.js --platform=node
Wait, --platform=node? Yes, because Chrome extension APIs look like Node.js modules to a bundler. It’s weird, but it works.
Taming the Chrome API with TypeScript
Here’s where TypeScript pays for itself immediately. The Chrome API is a massive, loosely-typed beast. Trying to remember if tabs.query takes active or currentWindow first is a recipe for runtime errors. So we install the type definitions:
npm install -D @types/chrome
Now, magic happens. You get full autocomplete and type checking.
// This is now beautifully type-checked
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
// `tabs` is now correctly typed as an array of Tab objects
const tab = tabs[0];
if (!tab?.id) {
throw new Error("No active tab found. Where even are we?");
}
chrome.tabs.sendMessage(tab.id, { action: "greet" });
});
The type definitions even know which functions are available in which parts of the extension (e.g., chrome.devtools only exists in a devtools panel). This catches a huge class of “why isn’t this working” errors at compile time.
The Content Script Conundrum
Content scripts are the hardest part. They run in the isolated world of the page, meaning they get their own JavaScript environment. You can’t just access variables from the page’s script. This isolation is a security feature, but it’s a development headache.
You have two main problems: 1) communicating with your background script, and 2) interacting with the actual DOM of the page.
For communication, you use chrome.runtime.sendMessage. TypeScript makes this much safer. Let’s define a type for our messages.
// types.ts - A shared file for your entire extension
export type ExtensionMessage =
| { action: "getUserData" }
| { action: "updateContent"; payload: { newText: string } };
export type ExtensionResponse =
| { success: true; data: any }
| { success: false; error: string };
Now, in your content script and background script, you can import these types and create a type-safe messenger.
// content-script.ts
import { ExtensionMessage, ExtensionResponse } from "./types";
function sendSafeMessage(message: ExtensionMessage): Promise<ExtensionResponse> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(message, (response: ExtensionResponse) => {
// Chrome APIs are callback-based. Wrapping in a Promise is your first duty.
resolve(response);
});
});
}
// Use it
const response = await sendSafeMessage({ action: "getUserData" });
if (response.success) {
// Do something with response.data, with full type safety
}
The Pitfalls: It’s Still The Wild West
Never forget: your content script is injected into any page that matches your matches pattern in manifest.json. This includes pages that are still loading, pages built with ancient JavaScript frameworks, and pages that are actively hostile to extensions. Your code must be defensive and assume the DOM might be in any state.
// Bad: Assumes the element exists and is an input.
document.querySelector("#email").value = "new@value.com";
// Good: Defensive programming, saved by TypeScript type guards.
const emailInput = document.querySelector("#email");
if (emailInput instanceof HTMLInputElement) {
emailInput.value = "new@value.com";
} else {
// Handle the case where it's not found or is the wrong type.
// Maybe it's a <div> with `contenteditable`? Who knows. Not you.
}
The other major pitfall is the transient nature of background service workers in Manifest V3. They can be shut down by the browser at any time. This means you cannot store state in global variables. You must use chrome.storage (preferably chrome.storage.session for temporary data) for anything that needs to persist. TypeScript can help here too by giving you strongly typed storage wrappers.
The payoff for all this configuration pain is immense. You catch errors before they’re baked into your extension. You get autocomplete that actually understands the bizarre Chrome API. You can refactor with confidence. It turns the chaotic process of extension development into something that feels, well, engineered. And that’s a joke worth laughing at.