# How to make postMessage type-safe in under 350 bytes

> Typed-channel is a library for typed messages and requests across workers, iframes, tabs, and event buses.

- Date: 2026-10-28T00:00:00.000Z
- Authors: Pavel Grinchenko, Travis Turner
- Categories: DX, Open Source
- URL: https://evilmartians.com/chronicles/how-to-make-postmessage-type-safe-in-under-350-bytes

---

[typed-channel](https://evilmartians.com/opensource/typed-channel) makes `postMessage` type-safe in about 350 bytes. You define message names and payloads once, and TypeScript checks both sides. The same API works across Web Workers, iframes, tabs, and in-page event buses. Version 0.11 adds cancelable requests: cancel a request, and its handler stops too. 

Every frontend project reaches a point where two pieces of code need to exchange data, but don't share a memory space. For example: a page and a Web Worker, a page and an iframe, a Figma plugin’s UI and its sandbox, or two open browser tabs. The browser gives us `postMessage` for all of these cases.

`postMessage` is a good primitive, but you have to write a lot of code around it. And it’s the same code in every project. You need a `switch` on `event.data.type` to route messages, the message names are plain strings that TypeScript doesn’t check, and listeners are easy to forget to remove. Sooner or later, you'll also write a "send a message and wait for the reply" helper with its own ids, its own timeout, and its own bugs.

Evil Martians faced this scenario while building [Harmonizer](https://harmonizer.evilmartians.com/), an accessible color palette generator. Harmonizer runs color calculations in a worker, has an internal event bus, and ships as a Figma plugin. Each of these needed its own messaging code, and we didn’t want to write and maintain the same logic three times. So, we built [typed-channel](https://github.com/psd-coder/typed-channel), a small library that covers all three cases with a single typed API for sending and receiving messages.

Earlier versions of typed-channel only handled one-way messages. If one side needed an answer back, you still had to build request/response yourself. The latest release, typed-channel 0.11, added the missing part: requests. A request is a message the sender waits on, it has a typed answer, and an abort signal that also stops the work on the other side. 

One-way messages cost around 300 bytes, and request/response is a separate endpoint, so you only pay for it if you use it.

## The problems with plain `postMessage`

For context, let's look at the worker case, as it's the most common. The page sends a job, the worker sends results back. With plain `postMessage` it looks like this:

```typescript
// page.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

worker.addEventListener("message", (event) => {
  switch (event.data.type) {
    case "generated:color":
      renderColor(event.data.payload);
      break;
    case "done":
      hideSpinner();
      break;
  }
});

worker.postMessage({ type: "generate:colors", payload: { levels, hues } });
```

```typescript
// worker.ts
self.addEventListener("message", (event) => {
  if (event.data.type === "generate:colors") {
    for (const color of calculateColors(event.data.payload)) {
      self.postMessage({ type: "generated:color", payload: color });
    }
    self.postMessage({ type: "done" });
  }
});
```

This works, and for a small script it's fine. The problems crop up when the project grows.

1. Nothing is typed across the boundary: `event.data` is `any`. Message names are strings that TypeScript never checks, so a typo like `"generated:colors"` instead of `"generated:color"` compiles and fails at runtime. The payload shape is either undocumented, described in a comment, or defined in a type you import on both sides and cast to by hand.
2. No single place declares which messages each side sends and receives. The worker accepts one set of messages and sends another, and that contract exists only as the union of every `case` in every `switch`. When another developer adds a message, they need to find both sides and keep them in sync.
3. You have to handle cleanup yourself, because `addEventListener` doesn't return an unsubscribe function. To stop listening, you keep a reference to the handler and pass the same one to `removeEventListener`. In components that mount and unmount, this often causes duplicated handlers and leaked listeners.
4. Every boundary needs its own copy of this code. An iframe needs nearly the same code, plus `event.origin` checks and a target origin for `postMessage`. A `BroadcastChannel` needs the same code again. An in-page event bus uses `new CustomEvent("x", { detail })` to send and `event.detail` to read. The idea is the same across all four APIs, but each one needs its own boilerplate.

Further, the example only sends messages one way, **so it hides a fifth problem**. The moment the page needs an answer back from the worker, for example “compute this and give me the result,” you have to build request/response yourself. 

## Rewriting the worker example with typed-channel and `messages`

typed-channel splits the job in two: a **transport** moves a message across a boundary, a **channel** knows which messages exist and what they carry. You write the message types once and get `emit` and `on` that TypeScript checks on both sides.

```typescript
// messages.ts, shared by both sides
export type ClientMessages = {
  "generate:colors": { levels: Level[]; hues: Hue[] };
};

export type WorkerMessages = {
  "generated:color": GeneratedColor;
  done: never; // no payload
};
```

```typescript
// page.ts
import { createTypedChannel } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
import type { ClientMessages, WorkerMessages } from "./messages";

const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

// what we receive, what we send
const channel = createTypedChannel(
  createPostMessageTransport(worker),
);

channel.on("generated:color", renderColor); // (color: GeneratedColor) => void
channel.on("done", hideSpinner);

channel.emit("generate:colors", { levels, hues });
```

```typescript
// worker.ts: same maps, mirrored
const channel = createTypedChannel(
  createPostMessageTransport(self),
);

channel.on("generate:colors", (payload) => {
  for (const color of calculateColors(payload)) {
    channel.emit("generated:color", color);
  }
  channel.emit("done");
});
```

This change removes all four problems in one fell swoop:

1. TypeScript checks message names and payloads where you call `emit` and `on`, so a typo like `emit("generated:colors", ...)` won't compile.
2. The messages for each direction are defined in two type maps instead of being spread across `switch` cases, and the worker uses the same maps in reverse order.
3. `on` returns a function that removes the listener, so you don't need to keep a reference to the handler for `removeEventListener`.
4. None of this code is specific to workers. If you change the transport, the same channel works with an iframe, a `BroadcastChannel`, or an in-page event bus.

The core is 271 bytes minified and brotli-compressed, 341 with the `postMessage` transport, zero dependencies.

## typed-channel in practice: how Harmonizer uses it

Harmonizer uses typed-channel in three places: a worker, an in-page event bus, and a Figma plugin. The `emit` and `on` calls are the same in all three, and only the transport changes. 

### Workers and iframes with `postMessage`

As above, it's `createPostMessageTransport(worker)`. The same transport works for a `BroadcastChannel` or a `MessagePort`, because they all speak `postMessage` and `message` events. An iframe needs one more step. The page sends messages to `iframe.contentWindow`, but it receives them on its own `window`. The simplest way is to send the iframe one end of a `MessageChannel` and give each side's port to the transport.

### An in-page event bus with `EventTarget`

Harmonizer needs to tell a component "a new level was added, focus its input." That's a message with a typed payload, even though it never leaves the page:

```typescript
import { createEventTargetTransport } from "typed-channel/transports/eventTarget";

type AppEvents = {
  levelAdded: LevelId;
  hueAdded: HueId;
};

export const appEvents = createTypedChannel(createEventTargetTransport());

// anywhere in the app
appEvents.emit("levelAdded", levelId);
const stop = appEvents.on("levelAdded", (id) => focusLevel(id));
```

We end up with one map, since both directions are the same. Under the hood it's an `EventTarget` and a `CustomEvent`; you never touch either.

### A Figma plugin

This one was a Harmonizer-specific need, and it's a good test of the design. The plugin UI runs in an iframe while its code runs in a sandbox with its own messaging API: `figma.ui.postMessage` on one side and `parent.postMessage({ pluginMessage })` on the other. typed-channel has no built-in transport for that, because you can write one yourself. A transport is an object with two functions, `on` and `emit`, and writing one takes just a dozen lines:

```typescript
import type { AnyMessages, TypedChannelTransport } from "typed-channel";

function createFigmaPluginTransport<
  Inbound extends AnyMessages,
  Outbound extends AnyMessages,
>(): TypedChannelTransport {
  return {
    on(handler) {
      figma.ui.onmessage = handler;
      return () => (figma.ui.onmessage = undefined);
    },
    emit(message) {
      figma.ui.postMessage(message);
    },
  };
}

const uiChannel = createTypedChannel(createFigmaPluginTransport());

uiChannel.on("palette:generate", drawPalette);
uiChannel.emit("ready", { sandboxVersion, storedConfig });
```

The UI side is the mirror image, wrapping `parent.postMessage` and `window.onmessage`. After that, the plugin code reads the same as the worker code and the event bus code: typed names, typed payloads, one way to subscribe and unsubscribe.

A channel can also take several transports at once. Pass an `EventTarget` transport and a `BroadcastChannel` transport together, and one `emit` reaches the current tab and every other open tab:

```typescript
const channel = createTypedChannel([localTransport, broadcastTransport]);
```

## When a message isn't enough: requests

Everything so far is fire-and-forget. While that actually covers a lot, it doesn't handle cases where the sender needs an answer: "_compute this and give me the result_", "_do you have this in cache?_", "_are you ready?_" With plain messages you build request/response yourself, and it's always the same code:

```typescript
let nextId = 0;
const pending = new Map<number, { resolve: Function; reject: Function }>();

function callWorker(payload) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    worker.postMessage({ type: "compute", id, payload });
    setTimeout(() => {
      pending.delete(id);
      reject(new Error("timeout"));
    }, 5000);
  });
}

worker.addEventListener("message", (event) => {
  if (event.data.type === "compute:result") {
    pending.get(event.data.id)?.resolve(event.data.result);
    pending.delete(event.data.id);
  }
});
```

This helper still has several problems:
- When the timeout fires, the promise rejects, but the worker keeps computing.
- There's no way to cancel a request, so if the user navigates away, the result still arrives for a component that no longer exists.
- Errors thrown in the worker never reach the caller.
- If the worker crashes, pending requests wait out their full timeout and then fail with a generic timeout error.
- Message routing is still an untyped check on `event.data.type`.

typed-channel 0.11 adds a second factory, `createTypedRpcChannel`, that does all of this. It keeps `emit`/`on` for messages and adds `request`/`handle` for calls:

```typescript
// messages.ts
export type WorkerRequests = {
  "compute:colors": (payload: ComputePayload) => GeneratedColor[];
};
export type NoRequests = Record<never, never>;
```

```typescript
// page.ts
import { createTypedRpcChannel, requests } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";

// messages: what we receive, what we send. requests: what we handle, what we call.
const channel = createTypedRpcChannel(
  createPostMessageTransport(worker),
  requests(),
);

const colors = await channel.request("compute:colors", { levels, hues }, {
  signal: AbortSignal.timeout(5000),
});
```

```typescript
// worker.ts: maps mirrored again
const channel = createTypedRpcChannel(
  createPostMessageTransport(self),
  requests(),
);

channel.handle("compute:colors", (payload, { signal }) => calculateColors(payload, { signal }));
```

Each request is typed as a function. The parameter is the payload the caller sends, and the return type is the answer it gets back. In the type map, you write the return type as a plain value (`GeneratedColor[]`), rather than a promise. The handler can return that value directly or return a promise of it, and `request` resolves to the plain value either way. If the handler throws, the caller's `request` promise rejects.

`requests()` has no runtime cost. It only passes the two maps along so TypeScript can infer the types. The library also doesn't validate the payload or the result. Both are structured and cloned exactly as you pass them.

The part we're most happy with is cancellation. There's no `timeout` option, because the platform already has one: `request` accepts an `AbortSignal`, and `AbortSignal.timeout(5000)` allows setting a timeout value. Aborting the signal rejects the promise and sends an abort message to the other side, which aborts the `signal` the handler received. 

So, "_the user navigated away, or it took too long, whichever comes first_" looks like this:

```typescript
const controller = new AbortController();
unmount.then(() => controller.abort());

await channel.request("compute:colors", payload, {
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
});
```

...and the worker stops the work instead of finishing it for nobody.

One rule follows from this: a request with no handler on the other side never resolves by itself. It ends only through its signal. If the peer happens not to be ready, pass a timeout signal.

There are two smaller behaviors that are worth note. 
1. On a `BroadcastChannel`, where a request may reach several tabs, the first response wins and the others are ignored, so "_does any tab have this cached?_" is a single `request`.
2. Every channel also has an `unlisten()` method that disconnects it from its transports, for example when a component unmounts. On an RPC channel, it doesn't leave requests hanging: pending requests on this side are rejected, the other side gets an abort for each of them, and the `signal` of every handler still running is aborted.

Requests live in a separate factory on purpose. Messages stay at 271 bytes; `createTypedRpcChannel` is 1 kB and includes everything. Simply import the one you need.

## Try typed-channel

Just run:

```
npm install typed-channel
```

Then, start with `createTypedChannel` and a message map. Add `createTypedRpcChannel` when you need an answer back. Write a transport when you hit a boundary the built-in ones don't cover; it's two functions to do so.

The [documentation](https://typed-channel.psdcoder.dev/) has guides for messages, requests, and transports, and the [repo](https://github.com/psd-coder/typed-channel) has runnable examples for workers, iframes, and `BroadcastChannel`. We added requests because a real project asked for them, so if you're building something with it, or something is missing, open an issue!

---

**Make your code communicate safely** Evil Martians help startups build frontend systems that communicate safely and reliably across boundaries. [Contact Evil Martians](https://evilmartians.com/contact-us)
