Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object — the subject — changes state, all of its dependents are notified automatically. It is the foundation of event systems, reactive UIs, and pub/sub messaging. The goal is to decouple the source of a change from the code that reacts to it: the subject does not know who is listening, only that listeners exist. TypeScript adds real value here by typing the payload that flows from subject to observers, so subscribers cannot misread the data. This page builds a generic, type-safe observer and a typed event emitter.
The problem it solves
When one object must keep others in sync, calling them directly couples the subject to every dependent. Each new dependent means editing the subject, and the subject ends up importing modules it should not care about.
// ❌ The store reaches into unrelated modules to push updates
class Store {
save(data: string) {
logger.write(data);
ui.render(data);
analytics.track(data); // adding a 4th consumer means editing Store again
}
}
Observer inverts this: dependents subscribe to the subject, and the subject simply broadcasts. It no longer knows or names its consumers.
A generic, type-safe subject
A small generic Subject<T> holds a set of listener callbacks and notifies them with a strongly typed payload. Returning an unsubscribe function is the modern idiom — it avoids leaking listeners.
type Listener<T> = (value: T) => void;
class Subject<T> {
private readonly listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify(value: T): void {
for (const listener of this.listeners) listener(value);
}
}
const temperature = new Subject<number>();
const unsubscribe = temperature.subscribe((celsius) => {
console.log(`Now ${celsius}°C`);
});
temperature.notify(21); // "Now 21°C"
unsubscribe(); // listener removed
temperature.notify(25); // (nothing logged)
Because Subject<number> parameterizes the payload, the compiler guarantees every listener receives a number. Using a Set makes adding and removing listeners O(1) and prevents accidental duplicate registrations.
A typed multi-event emitter
Real applications emit several event kinds. A mapped type keyed by event name lets one emitter carry a different payload per event while staying fully checked.
type EventMap = {
login: { userId: string };
logout: { userId: string; reason: string };
error: Error;
};
class Emitter<Events extends Record<string, unknown>> {
private handlers: { [K in keyof Events]?: Set<(p: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, handler: (p: Events[K]) => void): () => void {
(this.handlers[event] ??= new Set()).add(handler);
return () => this.handlers[event]?.delete(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.handlers[event]?.forEach((h) => h(payload));
}
}
const bus = new Emitter<EventMap>();
bus.on("login", (p) => console.log(p.userId)); // p is { userId: string }
bus.emit("logout", { userId: "u1", reason: "idle" }); // ✅
// ❌ Error: Property 'reason' is missing in type '{ userId: string }'.
bus.emit("logout", { userId: "u1" });
The generic K extends keyof Events ties the event name to its payload type, so emit("logout", …) demands exactly the logout shape and the handler receives that same type. Mistyped event names or payloads are rejected at compile time.
Guarding against listener errors
A single throwing listener should not stop the rest from running. Wrap each call so the subject stays robust.
notify(value: T): void {
for (const listener of this.listeners) {
try {
listener(value);
} catch (err) {
console.error("observer failed", err);
}
}
}
Isolating failures keeps one buggy subscriber from silently breaking every other consumer of the same event.
When to use and pitfalls
| Strength | Pitfall |
|---|---|
| Decouples subject from consumers | Easy to leak listeners if you never unsubscribe |
| Many reactors to one change | Notification order is not guaranteed |
| Typed payloads catch mismatches | Cascading notifications can be hard to trace |
| Foundation for reactive systems | Synchronous loops can block on slow handlers |
Always keep the unsubscribe function and call it on teardown (component unmount, request end). Forgotten listeners are one of the most common memory leaks in long-lived apps.
Use Observer when several independent parts of a system must react to the same change and you want the source to stay ignorant of them. Avoid it for simple, fixed one-to-one calls, where a direct method invocation is clearer than an event hop.
Best Practices
- Return an unsubscribe function from
subscribe/onand always call it on cleanup. - Type payloads with generics or an event map so consumers can’t misread data.
- Store listeners in a
Setto dedupe and remove in O(1). - Wrap each listener call in
try/catchso one failure can’t break the others. - Do not rely on notification order; if order matters, model it explicitly.
- Keep emit handlers fast or hand off async work to avoid blocking the loop.
- Prefer a battle-tested library (RxJS, Node
EventEmitter) for complex needs.