Unsubscribing removes one registration. Disposing ends the lifetime of an entire channel. Our contract makes dispose idempotent, clears future listeners, returns false from later publish calls, and rejects later subscriptions with an Error.
These operations share state through a closure, not through a type alias. A Channel<T> declaration can describe the methods and their return values, but runtime guards implement the policy. A boolean publish result distinguishes an accepted publish from one rejected because its channel is disposed.
A snapshot already captured by a current publish is allowed to finish even if a listener disposes the channel. This choice keeps one dispatch internally consistent. New publish calls see the disposed flag. A stricter “stop the current snapshot” rule would be a different contract and would need different tests.
Nested publish is reentrant: a callback can publish again before its outer call completes. The implementation permits it, so callers must avoid unbounded recursion. Disposal does not magically cancel async work started elsewhere; our supported listeners are synchronous.
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.
let disposed = false;
function publish(value: string): boolean {
if (disposed) {
return false;
}
console.log(value);
return true;
}
function dispose(): void {
disposed = true;
}
console.log(publish("Types"));
dispose();
dispose();
console.log(publish("hidden"));Enable JavaScript to edit and type-check this example.
Check your prediction
Types
true
false Check the current snapshot and the next call separately
Disposal ends future use of the channel; it does not necessarily interrupt a dispatch already holding a snapshot. The chosen policy allows captured listeners to finish. A callback disposing the channel therefore creates two observable questions: what happens now and what happens on the next publish?
Treat repeated disposal and old cleanup handles as normal lifecycle interactions. Both should be harmless under this contract. New subscriptions after disposal must still be rejected so callers cannot accidentally revive a finished channel.
const events: string[] = [];
let disposed = false;
const listeners = [
() => {
events.push("dispose");
disposed = true;
},
() => {
events.push("captured");
}
];
function publish(): boolean {
if (disposed) {
return false;
}
for (const listener of [...listeners]) {
listener();
}
return true;
}
console.log(publish());
console.log(events.join(","));
console.log(publish());Enable JavaScript to edit and type-check this example.
Check your prediction
true
dispose,captured
false The first dispatch was accepted and completes its captured work. The second sees the disposed flag before starting. Stopping halfway through the first would be another valid policy, but not this one.
let disposed = false;
let releases = 0;
function dispose(): void {
if (!disposed) {
disposed = true;
releases++;
}
}
dispose();
dispose();
console.log(releases);Enable JavaScript to edit and type-check this example.
Check your prediction
1 The counter represents releasing an owned resource once. Idempotence is runtime behavior; a dispose:()=>void annotation alone cannot provide it.
Try the rule on new inputs
Test disposal before subscribe, before publish and inside the first listener. Verify the exact captured-listener order and the result of later calls. Keep cleanup safe even when its original token was already removed.
Build it yourself
Disposal & Lifetime: practise
Implement a generic channel with a disposed flag, synchronous undefined-returning listeners, cleanup and publish returning boolean. Subscribe a logger, dispose twice, and print false for a later publish. Assert subscribing afterward throws.
// 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
Check the lifetime state at the start of subscribe and publish.
Hint 2: a first step
Dispose sets the flag and clears the Set; cleanup may still harmlessly delete its old token.
Show one solution
type Listener<T> = (value: T) => undefined;
function createChannel<T>() {
const records = new Set<{ listener: Listener<T> }>();
let disposed = false;
return {
subscribe(listener: Listener<T>): () => void {
if (disposed) {
throw new Error("Disposed");
}
const token = { listener };
records.add(token);
return () => {
records.delete(token);
};
},
publish(value: T): boolean {
if (disposed) {
return false;
}
for (const token of [...records]) {
token.listener(value);
}
return true;
},
dispose(): void {
disposed = true;
records.clear();
}
};
}
const channel = createChannel<string>();
const remove = channel.subscribe(value => {
console.log(value);
});
channel.dispose();
channel.dispose();
remove();
console.log(channel.publish("hidden"));
let rejected = false;
try {
channel.subscribe(() => {});
} catch {
rejected = true;
}
if (!rejected) {
throw new Error("Disposed channel accepted subscription");
}Enable JavaScript to edit and type-check this example.
falseTrace 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
Dispose during the first callback and assert a second already captured callback still runs. Then publish again and assert no callback runs. Make both expectations explicit in a regression test.
Completion is saved in this browser only. It does not measure mastery.