Type-safe, promise-based message passing for Chrome extensions (Manifest V3)
Define your message contracts once as TypeScript types, and get full type inference across your entire Chrome extension — background scripts, content scripts, popups, side panels, and options pages.
Installation · Quick Start · Full API Reference · Examples · Part of @zovo/webext · License
@theluckystrike/webext-messaging is a lightweight TypeScript library that provides type-safe, promise-based message passing for Chrome extensions built with Manifest V3.
Chrome's native chrome.runtime.sendMessage and chrome.tabs.sendMessage APIs use callbacks and chrome.runtime.lastError for error handling — which means you lose all type safety and must manually check for errors. This library wraps those APIs to provide:
- Full TypeScript inference — define your message types once, get autocomplete everywhere
- Promise-based API — use modern
async/awaitinstead of callback hell - Proper error handling — no more checking
chrome.runtime.lastError - Bidirectional communication — send messages from content scripts ↔ background, and background → content scripts
Without type-safe messaging, you likely have code that looks like this:
// ❌ Old way — no type safety, callback-based, error-prone
chrome.runtime.sendMessage(
{ type: "GET_USER", payload: { id: 42 } },
(response) => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError.message);
return;
}
// response could be anything!
console.log(response.name); // TypeScript has no idea what this is
}
);With this library, you get:
// ✅ New way — fully typed, promise-based, safe
const response = await msg.send("getUser", { id: 42 });
console.log(response.name); // TypeScript knows exactly what this is!| Feature | Description |
|---|---|
| Fully Typed | Define message contracts as TypeScript types with full inference across your entire extension |
| Promise-Based | Modern async/await API instead of callbacks |
| Bidirectional | Send messages between background ↔ content scripts with send() and sendTab() |
| Handler Maps | Register multiple handlers in one place with onMessage() |
| Proper Errors | MessagingError wraps chrome.runtime.lastError properly |
| Zero Dependencies | Only TypeScript and the Chrome API — no runtime bloat |
| Manifest V3 Ready | Designed for service workers and modern Chrome extension patterns |
| TypeScript First | Every function, payload, and response is fully typed |
| Small Bundle | Under 2KB minified + gzipped |
- Node.js 18+
- TypeScript 5.0+
- A Chrome extension project
npm install @theluckystrike/webext-messagingOther package managers
# pnpm
pnpm add @theluckystrike/webext-messaging
# yarn
yarn add @theluckystrike/webext-messagingThis package includes TypeScript type definitions out of the box. No additional @types packages required!
Create a type that describes all the messages in your extension:
// types/messages.ts
export type Messages = {
// Key: message type name
// Value: { request: input shape, response: output shape }
getUser: {
request: { id: number };
response: { name: string; email: string };
};
ping: {
request: { timestamp: number };
response: { pong: true; serverTime: number };
};
setSettings: {
request: { theme: "light" | "dark"; notifications: boolean };
response: { success: boolean };
};
};In each of your extension's contexts (background, content script, popup), create a messenger instance:
import { createMessenger } from "@theluckystrike/webext-messaging";
import type { Messages } from "./types/messages";
const msg = createMessenger<Messages>();In your background service worker, register handlers for incoming messages:
// background.ts
import { createMessenger } from "@theluckystrike/webext-messaging";
import type { Messages } from "./types/messages";
const msg = createMessenger<Messages>();
// Register all handlers at once
msg.onMessage({
getUser: async ({ id }) => {
// Simulate an async database lookup
const user = await database.getUser(id);
return {
name: user.name,
email: user.email,
};
},
ping: ({ timestamp }) => {
// Synchronous handlers work too!
return {
pong: true,
serverTime: Date.now(),
};
},
setSettings: async ({ theme, notifications }) => {
await storage.set({ theme, notifications });
return { success: true };
},
});
console.log("Background service worker initialized");From content scripts, popups, side panels, or options pages, send messages to the background:
// content-script.ts or popup.ts
import { createMessenger } from "@theluckystrike/webext-messaging";
import type { Messages } from "./types/messages";
const msg = createMessenger<Messages>();
// Send to background service worker
async function onGetUser(id: number) {
try {
const user = await msg.send("getUser", { id });
console.log(`Hello, ${user.name}!`); // Fully typed!
} catch (error) {
if (error instanceof MessagingError) {
console.error("Failed to get user:", error.message);
}
}
}
async function onPing() {
const response = await msg.send("ping", { timestamp: Date.now() });
console.log("Ping latency:", response.serverTime - Date.now(), "ms");
}
// Update settings
async function onSetTheme(theme: "light" | "dark") {
await msg.send("setSettings", { theme, notifications: true });
}From the background service worker, send messages to content scripts in specific tabs:
// background.ts
const msg = createMessenger<Messages>();
// Send to a content script in a specific tab
async function notifyContentScript(tabId: number) {
const response = await msg.sendTab(
{ tabId },
"ping",
{ timestamp: Date.now() }
);
console.log("Content script responded:", response);
}
// Send to a specific frame within a tab
async function notifySpecificFrame(tabId: number, frameId: number) {
const response = await msg.sendTab(
{ tabId, frameId },
"ping",
{ timestamp: Date.now() }
);
}Creates a fully typed messenger instance bound to your message types.
import { createMessenger } from "@theluckystrike/webext-messaging";
type Messages = {
ping: { request: { ts: number }; response: { pong: true } };
};
const msg = createMessenger<Messages>();Returns a Messenger object with send(), sendTab(), and onMessage() methods.
messenger.send<K extends keyof M & string>(type: K, payload: RequestOf<M, K>): Promise<ResponseOf<M, K>>
Sends a typed message via chrome.runtime.sendMessage. Use from:
- Content scripts → background service worker
- Popups → background service worker
- Options page → background service worker
- Side panel → background service worker
Parameters:
| Parameter | Type | Description |
|---|---|---|
type |
K |
The message type key from your MessageMap |
payload |
RequestOf<M, K> |
The request payload (fully typed!) |
Returns: Promise<ResponseOf<M, K>> — resolves with the typed response, rejects with MessagingError on failure.
Example:
const response = await msg.send("getUser", { id: 42 });
// TypeScript knows: response is { name: string; email: string }messenger.sendTab<K extends keyof M & string>(options: TabMessageOptions, type: K, payload: RequestOf<M, K>): Promise<ResponseOf<M, K>>
Sends a typed message to a specific tab via chrome.tabs.sendMessage. Use from:
- Background service worker → content scripts
- Popup → content scripts (if you have the tab ID)
Parameters:
| Parameter | Type | Description |
|---|---|---|
options |
TabMessageOptions |
{ tabId: number; frameId?: number } |
type |
K |
The message type key from your MessageMap |
payload |
RequestOf<M, K> |
The request payload (fully typed!) |
Returns: Promise<ResponseOf<M, K>>
Example:
// Send to tab
await msg.sendTab({ tabId: 123 }, "ping", { ts: Date.now() });
// Send to specific frame in a tab
await msg.sendTab({ tabId: 123, frameId: 0 }, "ping", { ts: Date.now() });Registers typed handlers for incoming messages. Returns an unsubscribe function.
Parameters:
| Parameter | Type | Description |
|---|---|---|
handlers |
HandlerMap<M> |
An object mapping message types to handler functions |
Handler signature: (payload: RequestOf<M, K>, sender: chrome.runtime.MessageSender) => ResponseOf<M, K> | Promise<ResponseOf<M, K>>
Returns: () => void — call to remove the listener
Example:
const unsubscribe = msg.onMessage({
getUser: async ({ id }, sender) => {
console.log(`Request from tab ${sender.tab?.id}`);
return { name: "Alice", email: "alice@example.com" };
},
ping: ({ ts }) => ({ pong: true }),
});
// Later, stop listening
unsubscribe();If you prefer functions over the messenger object, these are also exported:
| Function | Description |
|---|---|
sendMessage<M, K>(type, payload) |
Same as Messenger.send |
sendTabMessage<M, K>(options, type, payload) |
Same as Messenger.sendTab |
onMessage<M>(handlers) |
Same as Messenger.onMessage |
All types are exported for advanced use cases:
The base type for message contracts:
type MessageMap = Record<string, { request: unknown; response: unknown }>;Extracts the request type for a given message key:
type Request = RequestOf<Messages, "getUser">;
// { id: number }Extracts the response type for a given message key:
type Response = ResponseOf<Messages, "getUser">;
// { name: string; email: string }The wire format sent over chrome.runtime / chrome.tabs:
type Envelope = Envelope<Messages, "getUser">;
// { type: "getUser"; payload: { id: number } }The handler function signature:
type Handler = Handler<Messages, "getUser">;
// (payload: { id: number }, sender: MessageSender) =>
// { name: string; email: string } | Promise<{ name: string; email: string }>A partial map of handlers:
type MyHandlers = HandlerMap<Messages>;
// {
// getUser?: Handler<Messages, "getUser">;
// ping?: Handler<Messages, "ping">;
// setSettings?: Handler<Messages, "setSettings">;
// }Options for sending to a specific tab:
interface TabMessageOptions {
tabId: number;
frameId?: number;
}The full messenger interface:
interface Messenger<M extends MessageMap> {
send<K extends keyof M & string>(
type: K,
payload: RequestOf<M, K>
): Promise<ResponseOf<M, K>>;
sendTab<K extends keyof M & string>(
options: TabMessageOptions,
type: K,
payload: RequestOf<M, K>
): Promise<ResponseOf<M, K>>;
onMessage(handlers: HandlerMap<M>): () => void;
}A custom error class that wraps Chrome messaging failures:
import { MessagingError } from "@theluckystrike/webext-messaging";
try {
await msg.send("getUser", { id: 999 });
} catch (error) {
if (error instanceof MessagingError) {
console.error("Messaging failed:", error.message);
console.error("Original error:", error.originalError);
}
}The MessagingError class:
- Extends the built-in
Errorclass - Adds an
originalErrorproperty with the underlying error - Includes the message type and Chrome error details in the message
Here's how all the pieces fit together in a real Chrome extension:
// ============== shared/types/messages.ts ==============
export type Messages = {
getSettings: {
request: void; // no request payload
response: { theme: "light" | "dark"; language: string };
};
updateSettings: {
request: { theme: "light" | "dark"; language: string };
response: { success: boolean };
};
openTab: {
request: { url: string };
response: { tabId: number };
};
};
// ============== background/background.ts ==============
import { createMessenger } from "@theluckystrike/webext-messaging";
import type { Messages } from "../shared/types/messages";
const msg = createMessenger<Messages>();
// Initialize handlers
msg.onMessage({
getSettings: async () => {
const stored = await chrome.storage.local.get(["theme", "language"]);
return {
theme: stored.theme ?? "light",
language: stored.language ?? "en",
};
},
updateSettings: async ({ theme, language }) => {
await chrome.storage.local.set({ theme, language });
// Notify all tabs about the change
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
msg.sendTab({ tabId: tab.id }, "settingsUpdated", { theme, language });
}
}
return { success: true };
},
openTab: async ({ url }) => {
const tab = await chrome.tabs.create({ url });
return { tabId: tab.id! };
},
});
// ============== content-script/content.ts ==============
import { createMessenger } from "@theluckystrike/webext-messaging";
import type { Messages } from "../shared/types/messages";
const msg = createMessenger<Messages>();
// Get settings when page loads
async function init() {
const settings = await msg.send("getSettings", undefined);
applyTheme(settings.theme);
applyLanguage(settings.language);
}
// Listen for settings changes from background
msg.onMessage({
settingsUpdated: ({ theme, language }) => {
applyTheme(theme);
applyLanguage(language);
},
});// popup.tsx (React)
import { createMessenger } from "@theluckystrike/webext-messaging";
const msg = createMessenger<Messages>();
function Popup() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Get current user when popup opens
msg.send("getCurrentUser", {}).then(setUser).catch(console.error);
}, []);
const handleLogout = async () => {
await msg.send("logout", {});
setUser(null);
};
return (
<div>
{user ? (
<>
<h1>Welcome, {user.name}</h1>
<button onClick={handleLogout}>Logout</button>
</>
) : (
<p>Please log in</p>
)}
</div>
);
}// From background, send to a specific iframe within a tab
async function notifyIframe(tabId: number, frameId: number) {
try {
const response = await msg.sendTab(
{ tabId, frameId },
"iframeReady",
{ timestamp: Date.now() }
);
console.log("Frame acknowledged:", response);
} catch (error) {
if (error instanceof MessagingError) {
console.error("Frame might not be ready yet:", error.message);
}
}
}// Graceful degradation when background isn't available
async function safeSend<T>(type: string, payload: unknown): Promise<T | null> {
try {
return await msg.send(type as any, payload);
} catch (error) {
if (error instanceof MessagingError) {
// Log but don't throw — extension context might be invalidated
console.warn(`Message "${type}" failed:`, error.message);
return null;
}
throw error;
}
}
// Usage
const user = await safeSend<User>("getUser", { id: 42 });
if (user) {
console.log(user.name);
}Put your message types in a shared location and import them in all your extension contexts:
my-extension/
├── src/
│ ├── background/
│ │ └── background.ts
│ ├── content/
│ │ └── content.ts
│ ├── popup/
│ │ └── popup.tsx
│ └── shared/
│ └── types.ts ← shared message types
If a message doesn't need a request payload, use void:
type Messages = {
getStatus: { request: void; response: { online: boolean } };
ping: { request: void; response: { pong: true } };
};
// Usage
await msg.send("ping", undefined);
// or
await msg.send("ping", {});Always wrap message sends in try/catch to handle edge cases:
try {
const result = await msg.send("getData", { id: 1 });
} catch (error) {
if (error instanceof MessagingError) {
// Handle gracefully
}
}Always unsubscribe when your context is destroyed:
// In a React component
useEffect(() => {
const unsubscribe = msg.onMessage({
update: (data) => { /* ... */ },
});
return () => unsubscribe();
}, []);For the best experience, ensure your tsconfig.json has strict mode enabled:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}This library works with any Chrome extension targeting Manifest V3:
- ✅ Chrome 88+ (Manifest V3)
- ✅ Edge 88+
- ✅ Other Chromium-based browsers
Note: This library uses
chrome.runtimeandchrome.tabsAPIs, which are available in all modern Chromium-based browsers.
Contributions are welcome! Please see our Contributing Guide for details.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
# Install dependencies
npm install
# Run tests
npm test
# Build
npm run build
# Type check
npm run typecheckThis package is part of the @zovo/webext family — a collection of typed, modular utilities for Chrome extension development:
| Package | Description | npm |
|---|---|---|
| webext-messaging | Type-safe message passing | @theluckystrike/webext-messaging |
| webext-storage | Typed storage with schema validation | @theluckystrike/webext-storage |
| webext-tabs | Tab query and manipulation helpers | @theluckystrike/webext-tabs |
| webext-cookies | Promise-based cookies API | @theluckystrike/webext-cookies |
| webext-i18n | Internationalization toolkit | @theluckystrike/webext-i18n |
Looking for more Chrome extension guides? Check out these resources:
- Chrome Extension Documentation — Official Chrome docs
- Manifest V3 Migration Guide — Migrating from MV2 to MV3
- Chrome Extension Guide — Comprehensive guides and tutorials for building Chrome extensions
MIT License — see LICENSE for details.
Built with ❤️ by theluckystrike