typed-channel
v0.11.0
Published
A type-safe communication channel for sending and receiving messages between different contexts in a TypeScript environment
Maintainers
Readme
typed-channel
A type-safe communication channel for sending and receiving messages between different contexts in a TypeScript environment.
Documentation: Messages, Requests, Transports
Features
- ✅ Full TypeScript support with type checking for messages
- ✅ Multiple transport implementations (EventTarget, PostMessage, your own custom implementation)
- ✅ Request/response with abort and timeout
- ✅ Simple, lightweight API
- ✅ Zero dependencies
Video presentation
https://github.com/user-attachments/assets/d68aa980-6b95-4bff-ad12-e591842e03cb
Installation
# npm
npm install typed-channel
# pnpm
pnpm add typed-channel
# yarn
yarn add typed-channelCore Concepts
typed-channel provides a simple API for creating type-safe communication channels between different contexts in your application. This is particularly useful for:
- Communication between main thread and Web Workers
- Cross-frame communication using postMessage
- Communication between different parts of your application via events
- Any other event-based communication
Usage
Here we are using the EventTarget transport to create a simple typed event emitter:
Step 1: Define your message types
// Define the messages that can be sent through the channel
type Messages = {
// Message name : payload type
notify: { message: string };
clear: never; // Use 'never' for messages with no payload
};Step 2: Choose a transport
import { createTypedChannel } from "typed-channel";
import { createEventTargetTransport } from "typed-channel/transports/eventTarget";
// Create a transport appropriate for your use case
const transport = createEventTargetTransport<Messages>();
// Create the channel with your type definitions
const channel = createTypedChannel(transport);Step 3: Send and receive messages
// Type-safe event handling
const unsubscribeNotify = channel.on("notify", ({ message }) => {
console.log(`Received notification with message: ${message}`);
});
const unsubscribeClear = channel.on("clear", () => {
console.clear();
});
// Type-safe event emission
channel.emit("notify", { message: "Application is ready" });
setTimeout(() => {
channel.emit("clear");
// Remove all listeners
unsubscribeNotify();
unsubscribeClear();
}, 1000);Unidirectional vs Bidirectional Transports
Transports can be configured in two ways:
- Unidirectional: Same message types in both directions
- Bidirectional: Different message types for incoming and outgoing communications
Unidirectional transports are ideal for event buses within the same context, while bidirectional transports excel when communicating between different contexts (like main thread and worker).
Here's how to use both approaches:
// Unidirectional: Same message types for both directions (default)
const uniTransport = createEventTargetTransport<{ start: never; stop: never }>();
// Can receive and emit the same set of messages
uniTransport.on("start", () => {});
uniTransport.on("stop", () => {});
uniTransport.emit("start");
uniTransport.emit("stop");
// Bidirectional: Different message types for inbound and outbound
type InboundMessages = {
status: { code: number; message: string };
data: { items: unknown[] };
};
type OutboundMessages = {
fetch: { id: string };
cancel: never;
};
// Explicitly defining different types for incoming and outgoing messages
const biTransport = createPostMessageTransport<InboundMessages, OutboundMessages>(worker);
// Can receive only inbound messages
biTransport.on("status", ({ code, message }) => {});
biTransport.on("data", ({ items }) => {});
// Can emit only outbound messages
biTransport.emit("fetch", { id: "123" });
biTransport.emit("cancel");Multiple Transports
You can combine multiple transports to send messages to different targets:
import { createTypedChannel } from "typed-channel";
import { createEventTargetTransport } from "typed-channel/transports/eventTarget";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
const broadcastChannel = new BroadcastChannel("example-channel");
const broadcastTransport = createPostMessageTransport<Messages>(broadcastChannel);
const localTransport = createEventTargetTransport<Messages>();
// Messages will be sent to all tabs, including current one
const channel = createTypedChannel([localTransport, broadcastTransport]);Requests
A message is one-way. A request is a message the sender waits on: exactly one response, or a
failure, comes back for it. Requests come from a separate factory, createTypedRpcChannel,
which adds request and handle next to emit and on.
// shared.ts: requests the worker answers, and nothing in the other direction
export type WorkerRequests = {
compute: (params: { steps: number }) => number;
};
export type NoRequests = Record<never, never>;
// Messages still travel through the same channel
export type Messages = { progress: { done: number } };// page.ts
import { createTypedRpcChannel, requests } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
import type { Messages, NoRequests, WorkerRequests } from "./shared";
const transport = createPostMessageTransport<Messages>(worker);
// First map: what this side handles. Second map: what it calls on the peer.
const channel = createTypedRpcChannel(transport, requests<NoRequests, WorkerRequests>());
// Wait for the response, and stop waiting after 5 seconds
const total = await channel.request("compute", { steps: 6 }, { signal: AbortSignal.timeout(5000) });// worker.ts
const channel = createTypedRpcChannel(transport, requests<WorkerRequests, NoRequests>());
channel.handle("compute", ({ steps }, { signal }) => runSteps(steps, signal));A request with no handler on the other side gets no response: it ends only through its signal.
Always pass a timeout signal when the peer may be missing.
Read the Requests page for the full API: abort propagation, the rules list and the wire format.
Why two factories
Requests cost code: ids, a pending map, abort handling, error rebuilding. Two factories keep that code out of the bundle of a project that only sends messages. Minified and compressed with brotli:
| Import | Size |
| --------------------------------------------------- | ----- |
| createTypedChannel | 271 B |
| createTypedRpcChannel + requests | 994 B |
| both together | 997 B |
| createTypedChannel + createPostMessageTransport | 341 B |
Both factories share the same core, so adding createTypedChannel next to createTypedRpcChannel costs 3 B. Pick the factory per channel, by what that channel needs.
Available Transports
EventTarget Transport
Uses the standard DOM EventTarget interface for communication.
import { createEventTargetTransport } from "typed-channel/transports/eventTarget";
// Uses a new EventTarget instance by default
const transport = createEventTargetTransport();
// Or provide your own EventTarget
const customTarget = new EventTarget();
const transport = createEventTargetTransport(customTarget);PostMessage Transport
For communication with Workers, iframes, or other such contexts.
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
// With a Worker
const worker = new Worker("./worker.js");
const transport = createPostMessageTransport(worker);
// With an iframe
const iframe = document.querySelector("iframe");
const transport = createPostMessageTransport(iframe.contentWindow);BroadcastChannel Transport
For communication between different tabs/windows of the same origin.
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
const broadcastChannel = new BroadcastChannel("example-channel");
const transport = createPostMessageTransport(broadcastChannel);Custom Transports
You can create custom transports by implementing the TypedChannelTransport interface. This gives you flexibility to adapt typed-channel to any messaging system.
Here's a simple example of a custom transport structure:
import { type AnyMessageOf, type AnyMessages, type TypedChannelTransport } from "typed-channel";
function createNewTransport<
InboundMessages extends AnyMessages,
OutboundMessages extends AnyMessages,
>(source: EventTarget): TypedChannelTransport<InboundMessages, OutboundMessages> {
function on(handler: (message: AnyMessageOf<InboundMessages>) => void) {
// call the handler with every message coming from the transport
const listener = (event: Event) => {
handler((event as CustomEvent<AnyMessageOf<InboundMessages>>).detail);
};
source.addEventListener("message", listener);
// return a cleanup function
return () => source.removeEventListener("message", listener);
}
function emit(message: AnyMessageOf<OutboundMessages>) {
// pass the emitted message to the transport
source.dispatchEvent(new CustomEvent("message", { detail: message }));
}
return { on, emit };
}AnyMessageOf<T> now covers request traffic as well as the typed messages of T, so a
transport that spells the message union by hand no longer fits the contract. Use
AnyMessageOf and the transport keeps working.
A transport that only forwards the object as-is needs no change. One that inspects messages
must narrow with "rpc" in message before it reads type as a message name:
function on(handler: (message: AnyMessageOf<Messages>) => void) {
return subscribe((message) => {
if ("rpc" in message) {
// request traffic: forward it untouched
handler(message);
return;
}
console.log(`message ${message.type}`);
handler(message);
});
}in throws on a value that is not an object, and so does reading message.rpc. A transport
that can receive those must check typeof message === "object" && message !== null first.
Advanced Example: Figma Plugin Communication
Here's a real-world example of custom transport for Figma plugin UI communication:
import {
type AnyMessageOf,
type AnyMessages,
createTypedChannel,
type TypedChannelTransport,
} from "typed-channel";
export type PluginMessages = {
ready: never;
};
export type UIMessages = {
"window:resize": { width: number; height: number };
};
function createFigmaUiTransport<
InboundMessages extends AnyMessages,
OutboundMessages extends AnyMessages,
>(): TypedChannelTransport<InboundMessages, OutboundMessages> {
function on(handler: (message: AnyMessageOf<InboundMessages>) => void) {
const workerMessageHandler = (
e: MessageEvent<{ pluginMessage: AnyMessageOf<InboundMessages> }>,
) => {
handler(e.data.pluginMessage);
};
globalThis.onmessage = workerMessageHandler;
return () => (globalThis.onmessage = null);
}
function emit(message: AnyMessageOf<OutboundMessages>) {
parent.postMessage({ pluginMessage: message }, "*");
}
return { on, emit };
}
// Create the transport with appropriate type parameters
const transport = createFigmaUiTransport<PluginMessages, UIMessages>();
// Create a typed communication channel using our custom transport
export const pluginChannel = createTypedChannel(transport);This advanced example shows how to create a custom transport for Figma plugins, where:
- The
onmethod wraps Figma's message handling convention (where messages arrive viapluginMessageproperty) - The
emitmethod sends messages to the parent frame with the proper Figma message format - The transport specifies different types for inbound vs outbound messages
By following these patterns, you can adapt typed-channel to work with any messaging API.
Examples
You can find more examples in the examples directory:
- EventTarget Example
- Worker Example
- BroadcastChannel Example
- EventTarget Requests Example
- Worker Requests Example
- BroadcastChannel Requests Example
