Type safety
Strongbus checks every subscribe, emit, and pipe call against your EventMap.
Illegal combinations are compile errors — not runtime surprises.
This guide shows what TypeScript accepts and rejects for the three call sites you
use most. The repo’s exhaustive suite lives in
src/typeSafety_spec.ts (// @ts-expect-error
lines fail the build if they ever become valid).
import {Bus} from 'strongbus';
interface Events {
message: string;
count: number;
connected: void;
}
const bus = new Bus<Events>();
Subscribe
on / once
Allowed — known event keys; handler payload matches the map.
bus.on('message', (payload) => {
// payload: string
payload.toUpperCase();
});
bus.once('count', (n) => {
// n: number
n.toFixed(2);
});
bus.on('connected', () => {
// void payload — no useful value
});
Compile error
// unknown event
bus.on('mesage', () => {});
// wrong payload type in the handler
bus.on('count', (n: string) => {});
// wildcard is reserved (use tap for “all events”)
bus.on('*', () => {});
any
Allowed — a subset of known keys. The handler is an EventSink:
(event, payload). Discriminate on event to narrow payload.
bus.any(['message', 'count'], (event, payload) => {
if (event === 'message') {
// payload: string
} else {
// payload: number
}
});
Compile error
bus.any(['message', 'unknown'], () => {});
bus.any(['*'], () => {});
off
Same key and handler-shape rules as on. Pass the same function reference
you registered with on.
const handle = (payload: string) => { /* ... */ };
bus.on('message', handle);
bus.off('message', handle);
bus.off('nope', handle); // unknown event
bus.off('message', (n: number) => {}); // wrong payload type
Emit
Allowed — correlated (event, payload). Void events may omit the payload
(or pass null / undefined).
bus.emit('message', 'hello');
bus.emit('count', 42);
bus.emit('connected');
bus.emit('connected', null);
Compile error
bus.emit('message'); // string payload required
bus.emit('count', 'forty-two'); // wrong payload type
bus.emit('connected', 'x'); // void event cannot take a real payload
bus.emit('unknown', 1); // unknown event
bus.emit('*', undefined); // wildcard is reserved
emit never accepts a {event, payload} object. That keeps you from
re-emitting an uncorrelated pair after a multi-event subscribe.
bus.any(['message', 'count'], (event, payload) => {
// rejected: event and payload are a union pair, not one correlated emit
bus.emit(event, payload);
// ok after discriminating
if (event === 'message') {
bus.emit(event, payload);
}
});
Pipe
Observe — tap(handler)
The handler receives one correlated {event, payload} message. Narrow by
discriminating on message.event. Does not create a graph edge.
Allowed
bus.tap((message) => {
// message.payload is string | number | void until you narrow
if (message.event === 'message') {
message.payload.toUpperCase(); // string
} else if (message.event === 'count') {
message.payload.toFixed(0); // number
}
});
Compile error
bus.tap((message) => {
// cannot call string methods on the full payload union
message.payload.toUpperCase();
});
// handler typed for a disjoint event map
bus.tap((message: {event: 'other'; payload: boolean}) => {});
Filtered multi-hop — pipe(pred).pipe(dest)
When a bus is both a pipe target and a pipe source, allow passthrough with an explicit predicate on the outbound edge (see docs/pipe_limitations.md):
const mid = new Bus<{message: string}>();
const leaf = new Bus<Events>();
bus.pipe(mid);
mid.pipe((msg) => msg.event === 'message').pipe(leaf);
Bus downstream — pipe(bus)
Shared events must be payload-compatible: identical types, or a one-way widen
in the same primitive family ('a'|'b' → string, true → boolean, 1|2 → number).
Object payloads still require an exact match. Returns the downstream bus (for
chaining). Requires a real Bus instance — not a hand-rolled surface.
Allowed
const leaf = new Bus<{message: string; count: number}>();
const root = new Bus<Events>();
root.pipe(leaf); // shared keys agree; 'connected' is root-only (dropped)
const narrow = new Bus<{message: string}>();
const downstream = root.pipe(narrow);
// downstream is Bus<{message: string}>
downstream.on('message', (s) => s.toUpperCase());
Compile error
root.pipe(new Bus<{message: number}>()); // payload conflict on 'message'
root.pipe({
on() { /* ... */ },
emit() { return false; }
}); // not a Bus instance
Piping a wider source into a narrower destination is fine: extra source
events are not part of the downstream’s typed surface (they may still arrive at
runtime if you somehow listen outside the type system; typed on/pipe on the
narrow bus cannot name them).
const wide = new Bus<{foo: string; bar: string; baz: number}>();
const narrow = new Bus<{foo: string; bar: string}>();
const d = wide.pipe(narrow);
d.on('foo', (s) => s.toUpperCase());
d.on('baz', () => {}); // 'baz' is not on Narrow
Quick cheat sheet
| Call | Must be | Must not |
|---|---|---|
on / once / off |
Key in the map; matching handler payload | Unknown key, '*', wrong payload type |
any |
Array of known keys | Unknown key, '*' |
emit |
Correlated (event, payload) |
Missing/wrong payload, '*', {event, payload} object, uncorrelated union pair |
tap |
Handler for this map (or compatible overlap) | Disjoint handler map; using payload before discriminating |
pipe(bus) / pipe(pred).pipe(bus) |
Bus whose shared events are compatible |
Payload conflict; unsafe narrow; non-Bus duck type |
For variance (Bus<Wide> as SubscriptionSurface<Narrow>), generics, next /
scan triggers, and lifecycle hooks, see
src/typeSafety_spec.ts.