Skip to content
TypeScript ts patterns 4 min read

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

StrengthPitfall
Decouples subject from consumersEasy to leak listeners if you never unsubscribe
Many reactors to one changeNotification order is not guaranteed
Typed payloads catch mismatchesCascading notifications can be hard to trace
Foundation for reactive systemsSynchronous 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/on and always call it on cleanup.
  • Type payloads with generics or an event map so consumers can’t misread data.
  • Store listeners in a Set to dedupe and remove in O(1).
  • Wrap each listener call in try/catch so 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.
Last updated June 29, 2026
Was this helpful?