Skip to content

Migrating from v2 to v3

postal v3 is a ground-up TypeScript rewrite. The core idea — channels, topics, wildcard subscriptions — is the same. Almost everything else changed. This guide walks through each breaking change with before/after code so you can migrate methodically.

v2v3
ModuleGlobal postal object (UMD)Named exports (ESM + CJS)
DependencieslodashNone
Subscriber args(data, envelope)(envelope) — data is envelope.payload
Subscription chaining.debounce(), .throttle(), .constraint(), etc.Removed — compose your own
RPCAdd-on (postal.request-response)Built-in request() / handle()
Federationpostal.federation, postal.xframeaddTransport() + transport packages
TypesNoneFull TypeScript with inference
Error handlingPer-subscription .catch()AggregateError after all subscribers run
Channel cleanuppostal.reset()channel.dispose() + resetChannels()
- npm install postal lodash
+ npm install postal

Zero runtime dependencies. The lodash peer dependency is gone.

The global postal object is gone. Everything is a named export.

// v2
var postal = require("postal");
var ch = postal.channel("orders");

// v3
import { getChannel } from "postal";
const ch = getChannel("orders");

getChannel is a singleton factory — same name, same instance. Calling it with no arguments returns the default channel (named "__default__"). In v2, the default channel was "/".

This is the change that’ll touch the most lines. Subscribers no longer receive data as the first argument.

// v2 — data first, envelope second
ch.subscribe("order.placed", function (data, envelope) {
    console.log(data.sku);
    console.log(envelope.topic);
});

// v3 — envelope only, data is envelope.payload
ch.subscribe("order.placed", envelope => {
    console.log(envelope.payload.sku);
    console.log(envelope.topic);
});

Search pattern to find v2 subscribers in your codebase:

\.subscribe\(.*function\s*\(\s*data\s*,\s*env

The envelope itself has changed.

// v2 envelope
{
    channel: "orders",
    topic: "order.placed",
    data: { sku: "X" },       // ← "data"
    timeStamp: new Date(),     // ← Date object, camelCase "S"
    headers: { ... }           // ← resolver cache hints
}

// v3 envelope
{
    id: "550e8400-...",        // ← new: UUID
    type: "publish",           // ← new: "publish" | "request" | "reply"
    channel: "orders",
    topic: "order.placed",
    payload: { sku: "X" },     // ← renamed: "data" → "payload"
    timestamp: 1709571200000,  // ← Date.now(), lowercase "s"
    source?: "...",            // ← new: transport origin tracking
    replyTo?: "...",           // ← new: RPC routing
    correlationId?: "..."      // ← new: RPC correlation
}

Key renames:

  • envelope.dataenvelope.payload
  • envelope.timeStampenvelope.timestamp (lowercase s, now a number not a Date)
  • envelope.headers — removed entirely

The signature simplified. No more object form, no more callbacks.

// v2 — multiple forms
ch.publish("order.placed", { sku: "X" });
ch.publish({ topic: "order.placed", data: { sku: "X" } });
ch.publish("order.placed", { sku: "X" }, function (stats) {
    console.log(stats.activated, "subscribers fired");
});

// v3 — one form, no callback
ch.publish("order.placed", { sku: "X" });

The publish callback that reported { activated, skipped } stats is gone. If you need dispatch metrics, use a wire tap.

v2 had a rich chainable API on SubscriptionDefinition. v3 returns a plain unsubscribe function — no chaining, no middleware pipeline.

Here’s how to replace each method:

.constraint(predicate) / .constraints([...])

Section titled “.constraint(predicate) / .constraints([...])”

Filter inside your callback.

// v2
ch.subscribe("order.placed", handler).constraint(function (data) {
    return data.total > 100;
});

// v3
ch.subscribe("order.placed", env => {
    if (env.payload.total > 100) {
        handler(env);
    }
});

Track the previous value yourself.

// v2
ch.subscribe("sensor.reading", handler).distinctUntilChanged();

// v3
let prev: unknown;
ch.subscribe("sensor.reading", env => {
    if (env.payload !== prev) {
        prev = env.payload;
        handler(env);
    }
});

Track all seen values.

// v2
ch.subscribe("events.#", handler).distinct();

// v3
const seen = new Set();
ch.subscribe("events.#", env => {
    const key = JSON.stringify(env.payload);
    if (!seen.has(key)) {
        seen.add(key);
        handler(env);
    }
});

Use setTimeout / clearTimeout.

// v2
ch.subscribe("search.input", handler).debounce(300);

// v3
let timer: ReturnType<typeof setTimeout>;
ch.subscribe("search.input", env => {
    clearTimeout(timer);
    timer = setTimeout(() => handler(env), 300);
});

Standard throttle pattern.

// v2
ch.subscribe("mouse.move", handler).throttle(100);

// v3
let lastFired = 0;
ch.subscribe("mouse.move", env => {
    const now = Date.now();
    if (now - lastFired >= 100) {
        lastFired = now;
        handler(env);
    }
});

Wrap in setTimeout.

// v2
ch.subscribe("notification.show", handler).delay(500);
ch.subscribe("cleanup.run", handler).defer();

// v3
ch.subscribe("notification.show", env => {
    setTimeout(() => handler(env), 500);
});
ch.subscribe("cleanup.run", env => {
    setTimeout(() => handler(env), 0);
});

Count invocations and unsubscribe.

// v2
ch.subscribe("init.ready", handler).once();
ch.subscribe("batch.item", handler).disposeAfter(10);

// v3
let unsub: () => void;
unsub = ch.subscribe("init.ready", env => {
    handler(env);
    unsub();
});

// disposeAfter(n)
let count = 0;
let unsub2: () => void;
unsub2 = ch.subscribe("batch.item", env => {
    handler(env);
    if (++count >= 10) {
        unsub2();
    }
});

Use arrow functions or .bind().

// v2
ch.subscribe("order.placed", this.handleOrder).context(this);

// v3 — arrow function captures `this`
ch.subscribe("order.placed", env => {
    this.handleOrder(env);
});

Wrap your callback in try/catch.

// v2
ch.subscribe("risky.topic", handler).catch(function (err, data) {
    console.error("Handler failed:", err);
});

// v3
ch.subscribe("risky.topic", env => {
    try {
        handler(env);
    } catch (err) {
        console.error("Handler failed:", err);
    }
});

Same idea — catch and log.

// v2
ch.subscribe("thing.happened", handler).logError();

// v3
ch.subscribe("thing.happened", env => {
    try {
        handler(env);
    } catch (err) {
        console.warn(err);
    }
});

v2 returned a SubscriptionDefinition with an .unsubscribe() method (or you passed it to postal.unsubscribe()). v3 returns a plain function.

// v2
var sub = ch.subscribe("topic", handler);
sub.unsubscribe();
// or
postal.unsubscribe(sub);

// v3
const unsub = ch.subscribe("topic", handler);
unsub();

The API is similar but the callback signature changed.

// v2
var remove = postal.addWireTap(function (data, envelope, publishDepth) {
    console.log(envelope.channel, envelope.topic, data);
});

// v3
import { addWiretap } from "postal";

const remove = addWiretap(envelope => {
    console.log(envelope.channel, envelope.topic, envelope.payload);
});

Changes:

  • postal.addWireTap → named export addWiretap (lowercase t)
  • Callback receives only (envelope) — no separate data or publishDepth args
  • Wire tap errors are silently swallowed (they never affect dispatch)

v2 had no built-in RPC. You either used the postal.request-response add-on or rolled your own with paired subscribe/publish calls.

v3 has first-class RPC with automatic correlation, timeouts, and error propagation:

// v2 — ad-hoc request/reply
var replyTopic = "pricing.quote.reply." + someUniqueId;
ch.subscribe(replyTopic, function (data) {
    console.log("Got quote:", data);
});
ch.publish("pricing.quote.request", {
    sku: "X",
    replyTo: replyTopic,
});

// v3 — built-in RPC
const quote = await ch.request("pricing.quote", { sku: "X" });
console.log("Got quote:", quote);

// Handler side
ch.handle("pricing.quote", envelope => {
    return { total: envelope.payload.qty * 9.99, currency: "USD" };
});

See Subscriptions — Request / Handle for the full API.

v2 used postal.federation and packages like postal.xframe for cross-boundary messaging. v3 replaces this with a Transport interface and dedicated packages.

// v2 — postal.xframe (cross-iframe)
postal.fedx.addFilter([{ channel: "orders", topic: "#", direction: "both" }]);
postal.fedx.signalReady();

// v3 — MessagePort transport
import { addTransport } from "postal";
import { createMessagePortTransport } from "postal-transport-messageport";

const transport = createMessagePortTransport(iframe.contentWindow, {
    targetOrigin: "https://example.com",
});
const remove = addTransport(transport, {
    filter: env => env.channel === "orders",
    direction: "both",
});

Available transport packages:

  • postal-transport-messageport — iframes and Web Workers
  • postal-transport-broadcastchannel — cross-tab communication

See Transports for writing custom transports.

// v2 — per-subscription .catch()
ch.subscribe("topic", handler).catch(function (err) {
    // handle this subscriber's error
});

// v3 — AggregateError after all subscribers run
try {
    ch.publish("topic", data);
} catch (err) {
    if (err instanceof AggregateError) {
        for (const e of err.errors) {
            console.error(e);
        }
    }
}

In v3, if one subscriber throws, the rest still execute. All errors are collected and thrown as a single AggregateError after dispatch completes.

v3 channels can be explicitly torn down:

const ch = getChannel("orders");
// ... use channel ...
ch.dispose();

// After dispose:
// - All subscribers and handlers are removed
// - Pending RPC promises reject with PostalDisposedError
// - The channel is removed from the singleton registry
// - Any further calls throw PostalDisposedError

Disposal is idempotent — calling dispose() twice is safe.

v2 had a postal.configuration object with properties like DEFAULT_CHANNEL, SYSTEM_CHANNEL, enableSystemMessages, and the resolver cache settings. This is all gone in v3.

// v2
postal.configuration.enableSystemMessages = false;
postal.configuration.resolver.enableCache = false;

// v3 — no configuration object. These concepts don't exist.

The v2 system channel ("postal") that broadcast subscription.created / subscription.removed events is gone. If you relied on subscription lifecycle events, use the return value of subscribe() to track subscription state in your own code.

// v2
postal.reset();

// v3
import { resetChannels, resetWiretaps, resetTransports } from "postal";

// resetChannels() is the nuclear option — clears everything:
// channels, subscribers, handlers, pending RPCs, wiretaps, and transports.
resetChannels();

// Or reset selectively:
resetWiretaps();
resetTransports();

These v2 APIs have no direct replacement in v3:

v2 APIStatus
postal.noConflict()Removed — ESM modules don’t have global conflicts
postal.getSubscribersFor(options)Removed — no subscriber introspection API
postal.unsubscribeFor(options)Removed — track your own unsubscribe functions
postal.configuration.resolverRemoved — internal resolver is not configurable
postal.SubscriptionDefinitionRemoved — subscriptions are not objects
postal.ChannelDefinitionRemoved — channels are opaque objects
System channel eventsRemoved — no subscription.created / subscription.removed

v2 had no TypeScript types (community @types/postal existed but was limited). v3 ships full types with inference:

import type { Envelope, Channel, ChannelRegistry } from "postal";

// Module augmentation for compile-time inference
declare module "postal" {
    interface ChannelRegistry {
        orders: {
            "item.placed": { sku: string; qty: number };
            "item.cancelled": { sku: string; reason: string };
            "pricing.quote": {
                request: { sku: string; qty: number };
                response: { total: number; currency: string };
            };
        };
    }
}

See Getting Started — Typed channels for details.

  1. Update the installnpm install postal (remove lodash if it was only a postal dependency)
  2. Replace importsrequire("postal")import { getChannel, ... } from "postal"
  3. Replace postal.channel(name)getChannel(name)
  4. Update subscriber callbacks(data, envelope)(envelope), then data.xenvelope.payload.x
  5. Replace envelope.dataenvelope.payload everywhere
  6. Replace envelope.timeStampenvelope.timestamp (lowercase s) — it’s now Date.now() (a number), not a Date object
  7. Replace subscription chaining — see Removed subscription methods
  8. Replace .unsubscribe() with the returned function
  9. Update wire tapsaddWireTapaddWiretap, update callback signature
  10. Migrate federation to transports if applicable
  11. Replace postal.reset()resetChannels() in tests
  12. Run your tests — most remaining issues will be envelope.dataenvelope.payload misses