Skip to content

Getting Started

npm install postal
pnpm add postal
yarn add postal

postal ships as ESM and CJS with full TypeScript declarations. No peer dependencies.

import { getChannel } from "postal";

// Get (or create) a channel
const orders = getChannel("orders");

// Subscribe to a topic
const unsub = orders.subscribe("item.placed", envelope => {
    console.log("New order:", envelope.payload);
    console.log("Topic:", envelope.topic);
    console.log("Timestamp:", envelope.timestamp);
});

// Publish a message
orders.publish("item.placed", { sku: "WIDGET-42", qty: 3 });

// Unsubscribe when done
unsub();

getChannel is a singleton factory — calling it with the same name always returns the same channel instance. Subscribers receive the full Envelope, not just the payload.

const ch = getChannel("events");

// * matches exactly one segment
ch.subscribe("user.*", env => {
    // Matches: user.created, user.deleted
    // Does NOT match: user.profile.updated
});

// # matches zero or more segments
ch.subscribe("user.#", env => {
    // Matches: user, user.created, user.profile.updated
});

See Concepts for the full wildcard matching rules.

postal supports compile-time payload inference so publish, subscribe, and request know your payload types. There are two approaches — pick whichever fits your project.

Pass your topic map as a generic to getChannel:

type OrderTopicMap = {
    "item.placed": { sku: string; qty: number };
    "item.cancelled": { sku: string; reason: string };
};

const orders = getChannel<OrderTopicMap>("orders");
orders.publish("item.placed", { sku: "WIDGET-42", qty: 3 }); // ✅ Typed
orders.publish("item.placed", { sku: 42 }); // ❌ Type error

This is the simplest approach — define a type, pass it where you need it.

If the same channel is used across many files, you can register the type map globally via module augmentation. Every getChannel("orders") call infers the map automatically — no generic required:

declare module "postal" {
    interface ChannelRegistry {
        orders: {
            "item.placed": { sku: string; qty: number };
            "item.cancelled": { sku: string; reason: string };
        };
    }
}

const orders = getChannel("orders"); // OrderTopicMap inferred
orders.publish("item.placed", { sku: "WIDGET-42", qty: 3 }); // ✅ Typed

postal includes correlation-based RPC. Define RPC topics with { request, response } payloads:

declare module "postal" {
    interface ChannelRegistry {
        pricing: {
            "quote.request": {
                request: { sku: string; qty: number };
                response: { total: number; currency: string };
            };
        };
    }
}

const pricing = getChannel("pricing");

// Register a handler (one per topic per channel)
const unhandle = pricing.handle("quote.request", envelope => {
    const { sku, qty } = envelope.payload;
    return { total: qty * 9.99, currency: "USD" };
});

// Send a request — returns a Promise
const quote = await pricing.request("quote.request", {
    sku: "WIDGET-42",
    qty: 5,
});
console.log(quote); // { total: 49.95, currency: "USD" }