Lesson 55 of 80
Map event names to their payload types once, and every .on() and .emit() call checks itself — the handler's argument type comes from the event name you passed, automatically.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A plain event emitter (emitter.on("login", handler)) is stringly typed at its core — nothing stops you from listening for "logn" (a typo) or writing a handler that expects the wrong payload shape. TypeScript can't help with either mistake, because as far as it's concerned event is just a string and payload is just unknown.
The fix is to describe every event name and its payload shape as one type, up front, and have .on() and .emit() both index into that type by the event name you actually passed. Get the event name wrong and it's a typo TypeScript catches. Get the payload shape wrong and it's a type error, not a runtime surprise three components away.
interface AppEvents {
login: { userId: string };
logout: void;
error: { message: string; code: number };
}
class TypedEmitter<Events extends Record<string, unknown>> {
private listeners: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
(this.listeners[event] ??= []).push(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((handler) => handler(payload));
}
}
const emitter = new TypedEmitter<AppEvents>();
emitter.on("login", (payload) => {
console.log(payload.userId); // payload: { userId: string } — inferred, no cast needed
});
emitter.emit("login", { userId: "42" }); // ✅
emitter.emit("error", { message: "boom", code: 500 }); // ✅
emitter.emit("login", { message: "oops" }); // ❌ Property 'userId' is missing
emitter.on("logn", () => {}); // ❌ Argument of type '"logn"' is not assignable
The trick is the generic type parameter K extends keyof Events on both methods. K isn't fixed — it's whatever event name you pass at that specific call site — and TypeScript uses that same K to look up Events[K] for the payload type. on("login", ...) and on("error", ...) are, from the type checker's point of view, two different calls with two different, fully-checked handler signatures, even though they're the same method.
void payload still needs a second argument at the call site. emit("logout", undefined) — TypeScript doesn't let you drop the argument just because the payload type is void; you can write an overload for zero-argument events if that friction bothers you, but it's not automatic.TypedEmitter at all — add search: { query: string } to AppEvents and every .on("search", ...) call site immediately gets a checked handler. A big string-union type instead of a keyed interface doesn't scale the same way — every new event needs its payload threaded through a separate conditional type.// @ts-ignore, from calling emit("login", 123) at runtime — the safety is for people using the emitter from TypeScript, not a runtime validator.EventEmitter and the DOM's CustomEvent aren't typed this way natively. This pattern is something you build as a wrapper around a loosely-typed runtime API — the underlying emit/dispatchEvent call is still just passing a string and an untyped payload underneath.The K extends keyof Events constraint is the same generic-constraint mechanism from the generics lessons earlier in this track — a type parameter that's restricted to "one of this type's keys" rather than left wide open. Events[K] is an indexed access using keyof, the same operator from the keyof/typeof lesson, just used here to look up a payload type instead of listing keys. And the listeners map itself is a mapped type — the same { [K in X]: ... } shape from the mapped-types lesson, here producing an optional array per event instead of transforming an existing type.