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.

Or clone locally

git clone -b challenge/2026-09-20 https://github.com/niltonheck/typedrop.git