Typed Event Names

Events Generated From a Model

Many APIs use event names like nameChanged or order:created. Template literal types can generate these names from a model and connect each name to the right payload.

Example

type Order = { status: "PLACED" | "SHIPPED"; total: number; address: string };

type ChangeEvents<T> = { [K in keyof T as `${string & K}Changed`]: (newValue: T[K], oldValue: T[K]) => void };

function watch<T extends object>(initial: T, handlers: Partial<ChangeEvents<T>>) {
  const state = { ...initial };
  return {
    set<K extends keyof T>(key: K, value: T[K]) {
      const old = state[key];
      state[key] = value;
      const handler = (handlers as Record<string, ((n: T[K], o: T[K]) => void) | undefined>)[`${String(key)}Changed`];
      handler?.(value, old);
    },
  };
}

const order = watch<Order>({ status: "PLACED", total: 999, address: "Hyderabad" }, {
  statusChanged: (now, before) => console.log(`status: ${before} -> ${now}`),
  totalChanged: (now, before) => console.log(`total: ${before} -> ${now} (diff ${now - before})`),
});

order.set("status", "SHIPPED");
order.set("total", 1099);
order.set("address", "Secunderabad"); // no handler registered

Output

status: PLACED -> SHIPPED
total: 999 -> 1099 (diff 100)

Namespaced Events

Example

An event name like "order:shipped" is checked against the allowed entity and action lists (TS2345 for a typo).

type Entity = "order" | "payment" | "user";
type Action = "created" | "updated" | "deleted";
type DomainEvent = `${Entity}:${Action}`;

function publish(event: DomainEvent, id: string): void {
  console.log(event, id);
}

publish("order:created", "OD-1");
publish("payment:refunded", "PAY-9");

Compiler output (tsc)

Line 10: error TS2345: Argument of type '"payment:refunded"' is not assignable to parameter of type '"order:created" | "order:updated" | "order:deleted" | "payment:created" | "payment:updated" | "payment:deleted" | "user:created" | "user:updated" | "user:deleted"'.