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.data → envelope.payload
  • envelope.timeStamp → envelope.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, Web Workers, and Node.js worker threads
  • 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 install — npm install postal (remove lodash if it was only a postal dependency)
  2. Replace imports — require("postal") → import { getChannel, ... } from "postal"
  3. Replace postal.channel(name) → getChannel(name)
  4. Update subscriber callbacks — (data, envelope) → (envelope), then data.x → envelope.payload.x
  5. Replace envelope.data → envelope.payload everywhere
  6. Replace envelope.timeStamp → envelope.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 taps — addWireTap → addWiretap, 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.data → envelope.payload misses