TypeDrop
2026-09-20 Challenge
2026-09-20
Medium
Typed Event Emitter with Wildcard Subscriptions
You're building the real-time notification core for a collaborative document editor. Components across the app subscribe to strongly-typed events — cursor moves, document edits, presence updates — and the compiler must guarantee that every listener receives exactly the payload shape its event carries, with no unsafe casting.
Goals
- Define a branded `Unsubscribe` type (a callable intersected with a unique brand property) so it is structurally distinct from plain `() => void`.
- Express `WildcardListener<TMap>` as a distributive or mapped type that produces a union of narrowly-typed envelope handlers — one per event key — with no `unknown` payload.
- Implement `TypedEmitter<TMap>` so that `on`, `once`, `emit`, `onAny`, and `listenerCount` all typecheck under `strict: true` without any `any` or type assertions in the public API.
- Ensure `once` auto-unsubscribes after a single emission and that all `Unsubscribe` functions returned by every method correctly remove their associated listener.
challenge.ts
// Key types and main class signature
export interface DocEventMap {
"cursor:move": { userId: string; x: number; y: number };
"doc:edit": { userId: string; delta: string; timestamp: number };
"presence:join": { userId: string; displayName: string };
"presence:leave":{ userId: string };
"doc:save": { docId: string; revision: number };
}
// Branded cleanup function — distinguishable from plain () => void
export type Unsubscribe = (() => void) & { readonly __brand: "Unsubscribe" };
// Wildcard envelope — distributes over every key in TMap
export type WildcardListener<TMap extends EventMap> = /* your type here */;
export class TypedEmitter<TMap extends EventMap> {
on<K extends keyof TMap>(event: K, listener: Listener<TMap[K]>): Unsubscribe;
once<K extends keyof TMap>(event: K, listener: Listener<TMap[K]>): Unsubscribe;
emit<K extends keyof TMap>(event: K, payload: TMap[K]): void;
onAny(listener: WildcardListener<TMap>): Unsubscribe;
listenerCount<K extends keyof TMap>(event: K): number;
}
Hints (click to reveal)
Hints
- For the wildcard listener union, try a mapped type whose values are function types, then index into it with `keyof TMap` to distribute: `{ [K in keyof TMap]: (envelope: { event: K; payload: TMap[K] }) => void }[keyof TMap]`.
- To brand `Unsubscribe`, intersect the callable signature with a readonly phantom property: `(() => void) & { readonly __brand: 'Unsubscribe' }` — then cast only inside the class implementation when constructing the return value.
- Store listeners in a `Map<keyof TMap, Set<Listener<TMap[keyof TMap]>>>` — using a `Set` makes O(1) removal trivial and naturally prevents duplicate registrations.
Useful resources
Or clone locally
git clone -b challenge/2026-09-20 https://github.com/niltonheck/typedrop.git