The gateway joins the week’s ideas into one small reusable library. EventMap names payloads once. EventOf derives the correlated union, Channels derives the required ports, and satisfies checks that both ports were implemented. Dispatch keeps an explicit exhaustive switch instead of asserting that dynamic indexing preserves correlation.
The decoder accepts unknown, validates its fields, trims id/title and constructs fresh flat records. The only brand assertion lives after the id check. A brand is not a runtime authorization token. Extra input fields are dropped; readonly protects cooperative TS callers but does not freeze payloads at runtime.
Each channel owns subscription tokens and a disposed flag. Publish captures a snapshot; errors propagate synchronously; callbacks must return undefined. A current snapshot can finish after disposal, but future publishes return false and future subscriptions throw. This is an in-process synchronous API, not a message broker, persistent queue or async event system.
Download the complete project. The editor below combines the same source into one file because browser editors do not resolve relative module imports. First locate the three boundaries—decoder, channel and gateway—then run. Do not try to memorize the entire listing.
Trace one message through the pieces
Read this as a composition of the smaller exercises, not a new algorithm to memorize:
- Find
decodeBookId: the padded id becomes a checked, trimmed string. - Find
decodeEvent: the raw added record becomes one correlated AppEvent branch, with a trimmed title. - Find
receiveanddispatch: successful decoding selects the added branch; invalid input returns before dispatch. - Find
channels.added.publish: it captures the current subscription records and calls the title logger. - Follow the returned booleans: true means the channel accepted that publish. Cleanup removes the subscription, and dispose ends future work.
In createChannel<T>(): Channel<T>, the declared return contract also supplies context for the returned object’s methods. That is why its subscribe(listener) parameter needs no repeated annotation in this listing. The earlier exercises spelled it out; the accepted payload and callback return rules are the same.
Now predict the two output lines. Use the project files individually if the combined editor feels too long.
Predict, check, and run
Write the output before running. Use Check types first, then Check & run. Types disappear at runtime: explain both what the compiler checks and what the code actually does. Each editor is an independent ES module; await asynchronous work explicitly.
type Result<T> =
| {
ok: true;
value: T;
}
| {
ok: false;
message: string;
};
type Decoder<T> = (value: unknown) => Result<T>;
type Decoded<D> = D extends Decoder<infer T> ? T : never;
declare const bookIdBrand: unique symbol;
type BookId = string & { readonly [bookIdBrand]: "BookId" };
function decodeBookId(value: unknown): Result<BookId> {
if (typeof value !== "string" || value.trim() === "") {
return { ok: false, message: "Expected nonblank id" };
}
// The one audited assertion: the check above establishes this brand's rule.
return { ok: true, value: value.trim() as BookId };
}
type EventMap = {
added: {
readonly id: BookId;
readonly title: string;
};
removed: { readonly id: BookId };
};
type EventOf<E> = {
[K in keyof E]: {
readonly type: K;
readonly payload: E[K];
};
}[keyof E];
type AppEvent = EventOf<EventMap>;
const decodeEvent: Decoder<AppEvent> = value => {
if (
typeof value !== "object" ||
value === null ||
!("type" in value) ||
!("payload" in value)
) {
return { ok: false, message: "Expected event record" };
}
const payload = value.payload;
if (typeof payload !== "object" || payload === null || !("id" in payload)) {
return { ok: false, message: "Expected payload id" };
}
const id = decodeBookId(payload.id);
if (!id.ok) {
return id;
}
if (value.type === "removed") {
return { ok: true, value: { type: "removed", payload: { id: id.value } } };
}
if (value.type === "added") {
if (
!("title" in payload) ||
typeof payload.title !== "string" ||
payload.title.trim() === ""
) {
return { ok: false, message: "Expected nonblank title" };
}
return {
ok: true,
value: {
type: "added",
payload: { id: id.value, title: payload.title.trim() }
}
};
}
return { ok: false, message: "Unknown event type" };
};
// Returning undefined (rather than void) rejects Promise-returning TS listeners.
type Listener<T> = (value: T) => undefined;
type Channel<T> = {
subscribe: (listener: Listener<T>) => () => void;
publish: (value: T) => boolean;
dispose: () => void;
};
type Channels<E> = { [K in keyof E]: Channel<E[K]> };
function createChannel<T>(): Channel<T> {
const subscriptions = new Set<{ listener: Listener<T> }>();
let disposed = false;
return {
subscribe(listener) {
if (disposed) {
throw new Error("Channel disposed");
}
const token = { listener };
subscriptions.add(token);
return () => {
subscriptions.delete(token);
};
},
publish(value) {
if (disposed) {
return false;
}
// Snapshot policy: additions wait; already captured removals still run.
for (const token of [...subscriptions]) {
token.listener(value);
}
return true;
},
dispose() {
disposed = true;
subscriptions.clear();
}
};
}
function createGateway() {
const channels = {
added: createChannel<EventMap["added"]>(),
removed: createChannel<EventMap["removed"]>()
} satisfies Channels<EventMap>;
function dispatch(event: AppEvent): boolean {
switch (event.type) {
case "added":
return channels.added.publish(event.payload);
case "removed":
return channels.removed.publish(event.payload);
default:
return unreachable(event);
}
}
return {
channels,
receive(raw: unknown): Result<boolean> {
const event = decodeEvent(raw);
if (!event.ok) {
return event;
}
// Listener exceptions propagate; they are not mislabeled as invalid data.
return { ok: true, value: dispatch(event.value) };
},
dispose() {
channels.added.dispose();
channels.removed.dispose();
}
};
}
function unreachable(value: never): never {
throw new Error("Unhandled event");
}
const gateway = createGateway();
const unsubscribe = gateway.channels.added.subscribe(book => {
console.log(book.title);
});
const result = gateway.receive({
type: "added",
payload: { id: " b1 ", title: " Types " }
});
console.log(result.ok && result.value);
unsubscribe();
gateway.dispose();Enable JavaScript to edit and type-check this example.
Check your prediction
Types
true Build it yourself
Capstone: Typed Event Gateway: practise
In a small self-contained program, create a string channel with subscribe and publish using undefined-returning listeners. Publish Types and then unsubscribe before publishing Hidden. Print Types once. Explain where the full project adds validation and disposal.
// Start from the stated contract. Add a valid call and an edge case.Enable JavaScript to edit and type-check this example.
Hint 1: the idea
Keep one payload type in the channel; validation belongs at its external boundary.
Hint 2: a first step
Use a token record for the subscription and delete that token in cleanup.
Show one solution
type Listener<T> = (value: T) => undefined;
function createChannel<T>() {
const records = new Set<{ listener: Listener<T> }>();
return {
subscribe(listener: Listener<T>) {
const token = { listener };
records.add(token);
return () => {
records.delete(token);
};
},
publish(value: T) {
for (const token of [...records]) {
token.listener(value);
}
}
};
}
const channel = createChannel<string>();
const remove = channel.subscribe(value => {
console.log(value);
});
channel.publish("Types");
remove();
channel.publish("Hidden");Enable JavaScript to edit and type-check this example.
TypesTrace the implementation against the task before comparing styles. A passing example supports that case; use the changes below to challenge the wider contract.
Change it and test it
Run the full gateway with an added event missing title, then a removed event. Observe validation versus successful delivery. Follow an Error thrown by a subscriber and explain why receive must not convert it into a decoding failure.
Completion is saved in this browser only. It does not measure mastery.