Typed Message Contracts with TypeScript

Type every chrome.runtime message in an MV3 extension: a discriminated union of requests, a response map, typed send and handler helpers, runtime validation at the boundary, and versioning without breakage.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The popup sends { type: "getItems", folderId }; the service worker’s handler reads msg.folder and returns undefined; nobody notices until a user reports an empty list. Messages are the API between your extension’s contexts, but chrome.runtime.sendMessage is typed as “any value in, any value out”, so the compiler cannot catch a renamed field, a missing handler, or a response shape that drifted. A small contract module — one discriminated union of requests and one map of responses — gives every sender and handler compile-time checking, and a thin validation layer protects the boundary at runtime. This guide builds it. It belongs to message passing architecture.

Why messages need a contract

Inside one context, TypeScript checks every function call. Across contexts, the call becomes a serialised message, and the type information is lost at the boundary: the sender’s object is structured-cloned, the receiver gets any, and the reply comes back as any. In a small extension, the mismatch is rare; in a growing one, with several developers and messages flowing between the worker, popup, side panel, options page and content scripts, it is a steady source of bugs that only appear at runtime. A shared contract restores checking on both sides — the sender cannot send a malformed request, the handler map must cover every request type, and the response type follows from the request type. Because content scripts run in hostile pages, the contract also defines what the receiver validates at runtime, since compile-time types mean nothing to an attacker.

One contract, many contextsA shared messages.ts defines the request union and response map; popup, side panel and content scripts send through a typed helper; the service worker registers a handler map the compiler checks for completeness; a runtime validator guards the boundary.messages.tsRequest union + Responsessend<T>()popup, panel, contentstructured clonetypes erasedreceiver sidevalidate()runtime shape checkHandlers mapexhaustive by typeTyped replyResponses[T]
Types check your code; the validator checks everyone else's.

Step-by-step: a typed message layer

1. Define requests as a discriminated union

1// shared/messages.ts
2export type Request =
3  | { type: "items:list"; folderId: string }
4  | { type: "items:save"; item: { url: string; title: string } }
5  | { type: "auth:status" }
6  | { type: "settings:get"; keys: Array<"theme" | "density"> };
7
8export type RequestType = Request["type"];
9export type RequestOf<T extends RequestType> = Extract<Request, { type: T }>;

Execution context: a module imported by every context at build time; it produces no runtime code. The type field is the discriminant: narrowing on it gives the compiler the exact payload shape. Namespacing types with a prefix (items:, auth:) keeps the union readable as it grows and makes routing by feature easy.

2. Map each request to its response

 1export interface Responses {
 2  "items:list": { items: Array<{ id: string; title: string }> };
 3  "items:save": { id: string };
 4  "auth:status": { signedIn: boolean; email?: string };
 5  "settings:get": Partial<{ theme: "light" | "dark"; density: "compact" | "cozy" }>;
 6}
 7
 8export type Reply<T extends RequestType> =
 9  | { ok: true; data: Responses[T] }
10  | { ok: false; error: { code: string; message: string } };

Execution context: the same shared module. Keeping responses in an interface keyed by request type lets the compiler check that Responses has an entry for every request — add a request without a response and the handler map in step 4 fails to compile. The Reply envelope carries errors explicitly, because exceptions do not cross the message boundary.

What the contract catchesKinds of message bugs and whether compile-time types, runtime validation, or both catch them.BugCompile-timeRuntime validationRenamed payload fieldCaughtCaughtRequest with no handlerCaught (exhaustive map)Unknown type rejectedWrong response shapeCaughtOptionalMessage from hostile pageNot caughtCaughtOld build sending old shapeNot caughtCaught
Types catch your own mistakes; validation catches malformed input from anywhere.

3. Send through a typed helper

 1// shared/send.ts
 2import type { RequestOf, RequestType, Reply, Responses } from "./messages";
 3
 4export async function send<T extends RequestType>(req: RequestOf<T>): Promise<Responses[T]> {
 5  const reply = (await chrome.runtime.sendMessage(req)) as Reply<T> | undefined;
 6  if (!reply) throw new Error(`no handler replied to ${req.type}`);
 7  if (!reply.ok) throw Object.assign(new Error(reply.error.message), { code: reply.error.code });
 8  return reply.data;
 9}
10
11// popup.ts
12const { items } = await send({ type: "items:list", folderId: "inbox" });   // items is typed

Execution context: any context that sends messages. The generic parameter is inferred from the type literal, so callers write ordinary object literals and get a correctly typed result. An undefined reply — no listener returned true, or the worker had no handler — becomes an explicit error rather than a silent undefined downstream.

4. Register handlers in an exhaustive map

 1// sw/handlers.ts
 2import type { RequestOf, RequestType, Responses } from "../shared/messages";
 3
 4type Handlers = { [T in RequestType]: (req: RequestOf<T>, sender: chrome.runtime.MessageSender) => Promise<Responses[T]> };
 5
 6export const handlers: Handlers = {
 7  "items:list":   async ({ folderId }) => ({ items: await listItems(folderId) }),
 8  "items:save":   async ({ item }) => ({ id: await saveItem(item) }),
 9  "auth:status":  async () => authStatus(),
10  "settings:get": async ({ keys }) => readSettings(keys),
11};

Execution context: the service worker. The mapped type Handlers requires a function for every request type, and each function’s argument and return types come from the contract. Remove a handler, or return the wrong shape, and the build fails. This is the single most valuable line of type machinery in the layer.

5. Validate at the boundary before dispatching

 1// sw/validate.ts
 2import { z } from "zod";
 3const schemas = {
 4  "items:list":   z.object({ type: z.literal("items:list"), folderId: z.string().max(64) }),
 5  "items:save":   z.object({ type: z.literal("items:save"), item: z.object({ url: z.string().url(), title: z.string().max(500) }) }),
 6  "auth:status":  z.object({ type: z.literal("auth:status") }),
 7  "settings:get": z.object({ type: z.literal("settings:get"), keys: z.array(z.enum(["theme", "density"])).max(10) }),
 8} satisfies Record<RequestType, z.ZodTypeAny>;
 9
10export function parse(msg: unknown): Request | null {
11  const type = (msg as { type?: unknown })?.type;
12  if (typeof type !== "string" || !(type in schemas)) return null;
13  const r = schemas[type as RequestType].safeParse(msg);
14  return r.success ? (r.data as Request) : null;
15}

Execution context: the service worker. Content scripts run inside pages that may be hostile, and older extension builds may still be running in open tabs after an update; neither respects your types. A runtime schema per request type — Zod here, but any validator works — rejects malformed messages before a handler sees them. satisfies makes the compiler check that every request type has a schema. Keep the validator’s output aligned with the TypeScript types by deriving one from the other where your tooling allows.

6. Wire the listener once

 1// sw/index.ts
 2chrome.runtime.onMessage.addListener((raw, sender, sendResponse) => {
 3  const req = parse(raw);
 4  if (!req) return;                                     // not ours, or malformed
 5  const handler = handlers[req.type] as (r: Request, s: chrome.runtime.MessageSender) => Promise<unknown>;
 6  handler(req, sender).then(
 7    (data) => sendResponse({ ok: true, data }),
 8    (err) => sendResponse({ ok: false, error: { code: err.code ?? "internal", message: String(err.message ?? err) } }),
 9  );
10  return true;
11});

Execution context: the service worker, at the top level. One listener dispatches every request; returning true keeps the channel open for the async reply in every engine. The single cast is the only place the type system is bypassed, and it is safe because parse and handlers are keyed by the same type.

A typed request end to endThe popup calls send with items:list; the worker's listener validates the raw message, dispatches to the typed handler, wraps the result in an ok envelope; the popup's helper unwraps and returns typed data.PopupListenerHandlersend({type:'items:list', folderId})parse() → validhandlers['items:list']{items}{ok:true, data:{items}}
Typed at both ends, validated in the middle.

7. Version the contract without breaking open tabs

1export type Request =
2  | { type: "items:list"; folderId: string }
3  | { type: "items:list.v2"; folderId: string; cursor?: string };   // added; old type kept

Execution context: the shared contract. After an extension update, content scripts injected by the previous version may keep running in open tabs and send old shapes. Add new message types rather than changing existing ones, keep handlers for old types for at least one release, and remove them only when telemetry shows old senders are gone. See versioning message schemas across updates.

Common mistakes

  • Typing sendMessage with a cast at each call. It hides drift; route everything through one typed helper.
  • Throwing in handlers and expecting the sender to catch. Errors do not cross the boundary; use the envelope.
  • Skipping runtime validation because “it’s typed”. Types do not exist at runtime.
  • Changing a message’s shape in place. Open tabs with old content scripts break.
  • Separate listeners per feature. Several async listeners each returning true can race to respond; dispatch from one.

Cross-browser variation

  • Chrome / Edge: return true + sendResponse is required for async replies; the pattern above handles it.
  • Firefox: accepts returned promises too, but the same code works unchanged; browser.runtime.sendMessage types come from webextension-polyfill typings if you use them.
  • Safari: same behaviour as Firefox; keep handlers fast, since very long-running replies have been less reliable in older Safari versions.

Verification

  1. Remove one handler from the map and confirm tsc fails.
  2. Send { type: "items:list", folder: "x" } (wrong field) from a content script console and confirm the worker ignores it.
  3. Throw inside a handler and confirm the sender’s send() rejects with the error code.
  4. Run unit tests that call handlers directly with typed requests, without a browser.

FAQ

Do I need Zod?

No — any validator, or hand-written guards, will do. The essential part is validating before dispatch.

Can ports use the same contract?

Yes. Define a union for port messages per port name and validate each onMessage payload the same way, as in streaming progress updates over a port.

Does this add bundle size?

The types add nothing. The validator adds a few kilobytes; for most extensions that is a good trade.

Other Core APIs & Cross-Browser Data Management Resources