TypeScript Project Setup for Extensions
Structure TypeScript for an MV3 extension with per-context tsconfigs — WebWorker types for the service worker, DOM types for pages and content scripts, and a shared project with neither.
Table of Contents
A single tsconfig.json with "lib": ["DOM", "WebWorker"] compiles an extension and quietly lies about it: the compiler lets the service worker reference document, lets content scripts use self.clients, and lets shared code depend on either. The mistakes surface at runtime, in the context that lacks the API. Splitting the project by context — the way the browser splits the runtime — makes those mistakes compile errors. This guide is part of build tooling and bundlers.
One project per runtime
Step-by-step
1. A base config every project extends
1// tsconfig.base.json
2{
3 "compilerOptions": {
4 "target": "ES2022",
5 "module": "ESNext",
6 "moduleResolution": "bundler",
7 "strict": true,
8 "noUncheckedIndexedAccess": true,
9 "skipLibCheck": true,
10 "composite": true,
11 "declaration": true,
12 "types": [] // opt in per project; nothing ambient by default
13 }
14}
Execution context: read by tsc. "types": [] is the important line: without it, every @types/* package in node_modules becomes ambient in every project, including @types/node, which would let browser code reference process without an error.
2. The shared project has no platform globals
1// src/shared/tsconfig.json
2{
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": { "lib": ["ES2022"], "types": ["chrome"], "outDir": "../../.tsbuild/shared" },
5 "include": ["./**/*.ts"]
6}
Execution context: read by tsc. chrome types are allowed here because chrome.storage and chrome.runtime exist in every context; DOM and WebWorker are not. A shared module that reaches for document now fails to compile rather than failing on the next cold start of the worker.
3. The worker project gets WebWorker, not DOM
1// src/worker/tsconfig.json
2{
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": { "lib": ["ES2022", "WebWorker"], "types": ["chrome"], "outDir": "../../.tsbuild/worker" },
5 "references": [{ "path": "../shared" }],
6 "include": ["./**/*.ts"]
7}
Execution context: read by tsc. The WebWorker lib types self, fetch, caches and clients; it also types self as a generic worker scope rather than a ServiceWorkerGlobalScope. Add a one-line declaration where you use service-worker-specific members:
1declare const self: ServiceWorkerGlobalScope;
Execution context: the top of the worker entry file. This narrows self so self.registration and self.skipWaiting are typed; nothing else needs it.
4. Pages and content scripts get DOM — separately
1// src/pages/tsconfig.json and src/content/tsconfig.json (identical apart from paths)
2{
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["chrome"], "outDir": "../../.tsbuild/pages" },
5 "references": [{ "path": "../shared" }],
6 "include": ["./**/*.ts"]
7}
Execution context: read by tsc. Keeping content scripts in their own project even though the lib is the same is about imports: a content script importing a pages module drags UI code into every matched website. Two projects make that a missing-reference error. The runtime reasons are in best practices for content script isolation in MV3.
5. Build all of them with one command
1// tsconfig.json at the root — a solution file with no sources of its own
2{
3 "files": [],
4 "references": [
5 { "path": "src/shared" },
6 { "path": "src/worker" },
7 { "path": "src/pages" },
8 { "path": "src/content" }
9 ]
10}
1npx tsc --build --noEmit false --emitDeclarationOnly
Execution context: your shell and CI. tsc --build type-checks each project in dependency order and caches results, so an unchanged project is not re-checked. The bundler still does the emitting; tsc here is only the type checker, which is why --emitDeclarationOnly writes nothing but declaration files into .tsbuild.
6. Type the message protocol in shared
The protocol is the contract between projects, so it belongs in the one they all reference.
1// src/shared/messages.ts
2export type Request =
3 | { type: "settings:read" }
4 | { type: "articles:list"; site: string; limit?: number };
5
6export type ResponseFor<T extends Request["type"]> =
7 T extends "settings:read" ? Settings :
8 T extends "articles:list" ? Article[] : never;
Execution context: a shared module with no runtime globals. Both the worker’s handler registry and each sender import these types, so renaming a message is a compile error in every project at once — the discipline described in typing chrome and browser APIs in TypeScript.
Editor experience and the cost of the split
The main objection to per-context projects is editor friction: a file must belong to exactly one project, and a stray file outside every include gets no type checking at all. Two small measures remove most of the friction.
First, make the directory tell you the context. If every file under src/worker/ is worker code, there is never a question about which project owns it, and editors resolve the nearest tsconfig.json automatically.
Second, add a catch-all check in CI that fails on orphaned files:
1# every .ts file must be included by some project
2comm -23 \
3 <(find src -name '*.ts' | sort) \
4 <(npx tsc --build --listFilesOnly 2>/dev/null | grep '/src/' | sed "s|$PWD/||" | sort)
Execution context: your shell. Any path printed is a file no project type-checks — usually a new directory someone created without a tsconfig.json. It is the one failure mode project references introduce, and this line closes it.
The cost of the split is a few configuration files. The benefit is that the three most expensive runtime mistakes in an extension — DOM code in the worker, UI code in a content script, and a renamed message nobody updated — become red squiggles.
Cross-browser variation
- Chrome / Edge:
@types/chromecovers the full surface including Chrome-only APIs. Types can lag new APIs by a few weeks; a local declaration file bridges the gap. - Firefox: use the webextension-polyfill’s bundled types for
browser.*. Firefox-only APIs such assidebarActionappear there and nowhere in@types/chrome. - Safari: no dedicated types. The polyfill types are the closest match; guard Safari gaps at runtime rather than in the type system.
- All three: the DOM and WebWorker libs describe the standard platform, not an engine. A method present in the lib can still be missing in an older Safari — the type checker cannot see engine versions.
Verification
- Add
document.titleto a worker file and confirm it fails to compile:
1npx tsc --build
2# src/worker/sync.ts:4:1 - error TS2584: Cannot find name 'document'.
Execution context: your shell. The error is exactly what the split exists to produce; if it compiles, the worker project still has the DOM lib.
- Import a pages module from a content script and confirm a missing-reference error.
- Rename a message type in
shared/messages.tsand confirm every sender and the handler fail to compile. - Run the orphan check and confirm it prints nothing.
FAQ
Can I use one tsconfig with // @ts-check comments instead?
You can, and it is better than no checking, but a single project cannot express “DOM here, not there”. The split is what makes cross-context mistakes detectable.
Does the bundler use these configs?
For path resolution, usually yes. For type checking, most bundlers skip it for speed — run tsc --build as a separate step in CI.
Where do test files go?
In their own project that references whichever context they test, with the test runner’s types added. Keeping them out of the production projects stops test globals leaking into shipped code.
How do I type import.meta.env from the bundler?
Declare it once in the shared project — interface ImportMetaEnv { readonly MODE: string; readonly API_BASE: string } in a .d.ts file — and list only the variables you actually expose. Declaring the whole environment as Record<string, string> invites exactly the wholesale inlining that leaks build-machine secrets into the bundle, the risk covered in handling API keys without shipping them in the bundle.
Related
- Typing chrome and browser APIs in TypeScript — the API types these projects consume.
- Sharing code between popup, options and side panel — the directory structure this mirrors.
- Bundling an MV3 extension with Vite — the emitter that sits alongside
tsc. - Build tooling and bundlers — the parent guide.