Skip to content
TR

Phase 4 · Generics · Lesson 4.3

Intermediate

Generic patterns you'll actually use

Typed results, repositories, event emitters, factories, groupBy/pluck helpers and builders: the generic patterns that show up in real codebases and take-home tests.

25 min

Knowing the syntax of generics is one thing. Recognising the handful of patterns they're used for is what makes you fast in a real codebase, and it's what take-home exercises and live-coding interviews test. This lesson builds six of them, each small enough to write from memory.

1. A typed Result<T, E>

Exceptions are invisible in a function's type: parseAge(s: string): number doesn't tell you it can throw. A result type puts failure into the signature, so callers can't forget it:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
 
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

Two details worth explaining in an interview:

  • E = Error is a default, so Result<number> means "a number, or an Error".
  • ok returns Result<T, never>. never is assignable to everything, so Result<number, never> fits into any Result<number, E>. Same for err with Result<never, E>.

Using it:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
const ok = <T,>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E,>(error: E): Result<never, E> => ({ ok: false, error });
 
function parseAge(input: string): Result<number, string> {
  const age = Number(input);
  if (!Number.isInteger(age) || age < 0) return err(`Not an age: ${input}`);
  return ok(age);
}
 
const result = parseAge("42");
 
// @ts-expect-error -- Property 'value' does not exist on type 'Result<number, string>'.
result.value;
 
if (result.ok) {
  console.log(result.value + 1); // result is { ok: true; value: number } here
} else {
  console.log(result.error.toUpperCase());
}

ok is a discriminant: checking it narrows the union, and value only exists on the success branch. You literally can't read the value without handling the failure.

2. A typed repository

Store any entity that has an id, keep the entity's full type on the way out:

interface Entity {
  id: string;
}
 
class Repository<T extends Entity> {
  private items = new Map<string, T>();
 
  add(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
 
  get(id: string): T | undefined {
    return this.items.get(id);
  }
 
  update(id: string, patch: Partial<Omit<T, "id">>): T | undefined {
    const current = this.items.get(id);
    if (!current) return undefined;
    const next = { ...current, ...patch };
    this.items.set(id, next);
    return next;
  }
}
 
interface User extends Entity {
  name: string;
  age: number;
}
 
const users = new Repository<User>();
users.add({ id: "u1", name: "Ada", age: 36 });
users.update("u1", { age: 37 });
 
// @ts-expect-error -- Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<User, "id">>'.
users.update("u1", { id: "u2" });

The constraint (T extends Entity) is what lets the class call item.id. Partial<Omit<T, "id">> makes every field optional except id, which can't be patched at all.

A key-value cache driven by a schema

When different keys hold different types, describe them in one schema type and index it:

class TypedStore<Schema> {
  private data = new Map<keyof Schema, unknown>();
 
  set<K extends keyof Schema>(key: K, value: Schema[K]): void {
    this.data.set(key, value);
  }
 
  get<K extends keyof Schema>(key: K): Schema[K] | undefined {
    return this.data.get(key) as Schema[K] | undefined; // safe: set() only stores Schema[K]
  }
}
 
type Settings = { theme: "light" | "dark"; fontSize: number };
 
const store = new TypedStore<Settings>();
store.set("theme", "dark");
// @ts-expect-error -- Argument of type '"huge"' is not assignable to parameter of type 'number'.
store.set("fontSize", "huge");
 
const size = store.get("fontSize");
//    ^? const size: number | undefined

The single as is contained inside the class, where you can prove it's correct. Callers never see it.

3. A typed event emitter

The pattern interviewers love, because it combines keyof, indexed access and constraints. Describe your events once as a map from name to payload:

class Emitter<Events extends Record<string, unknown>> {
  private handlers: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
 
  on<K extends keyof Events>(name: K, handler: (payload: Events[K]) => void): () => void {
    const list = (this.handlers[name] ??= []);
    list.push(handler);
    return () => {
      this.handlers[name] = list.filter((h) => h !== handler);
    };
  }
 
  emit<K extends keyof Events>(name: K, payload: Events[K]): void {
    this.handlers[name]?.forEach((handler) => handler(payload));
  }
}
 
type AppEvents = {
  login: { userId: string };
  logout: undefined;
  error: Error;
};
 
const bus = new Emitter<AppEvents>();
 
bus.on("login", (payload) => {
  console.log(payload.userId);
  //          ^? (parameter) payload: { userId: string; }
});
 
bus.emit("error", new Error("boom"));
bus.emit("logout", undefined);
 
// @ts-expect-error -- Argument of type '"signup"' is not assignable to parameter of type 'keyof AppEvents'.
bus.emit("signup", {});
// @ts-expect-error -- Property 'userId' is missing in type '{}' but required in type '{ userId: string; }'.
bus.emit("login", {});

How it fits together:

  • K extends keyof Events restricts the name to known events, and keeps it as a literal ("login"), not string.
  • Events[K] looks up the payload type for that name, so the handler's parameter is typed without annotations.
  • The { [K in keyof Events]?: ... } field is a mapped type (covered in the next phase): one optional handler list per event name.

Quick check

Which line is a compile error?

declare class Emitter<Events extends Record<string, unknown>> { // the Emitter class above
  on<K extends keyof Events>(name: K, handler: (payload: Events[K]) => void): () => void;
  emit<K extends keyof Events>(name: K, payload: Events[K]): void;
}
type DoorEvents = { open: { id: number }; close: { id: number; at: Date } };
declare const doors: Emitter<DoorEvents>;
 
doors.on("open", (e) => console.log(e.id));                 // Line A
doors.on("open", (e) => console.log(e.name));               // Line B
doors.emit("close", { id: 1, at: new Date() });             // Line C

Spot the error

A teammate describes events with an interface and plugs it into the same Emitter. It fails. Why?

declare class Emitter<Events extends Record<string, unknown>> {} // the Emitter class above
 
interface ShopEvents {
  purchase: { sku: string; qty: number };
  refund: { sku: string };
}
 
const shop = new Emitter<ShopEvents>();
Show the answer

Type 'ShopEvents' does not satisfy the constraint 'Record<string, unknown>'. Index signature for type 'string' is missing in type 'ShopEvents'.

Record<string, unknown> has a string index signature. Object-literal type aliases get an implicit index signature, so type ShopEvents = {...} is assignable to it. Interfaces don't, because interfaces can be augmented later by declaration merging, so TypeScript won't assume it knows all their keys.

Two fixes: use a type alias for the event map, or loosen the constraint so it doesn't demand an index signature:

class Emitter<Events extends object> {
  private handlers: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
 
  on<K extends keyof Events>(name: K, handler: (payload: Events[K]) => void): void {
    (this.handlers[name] ??= []).push(handler);
  }
}
 
interface ShopEvents {
  purchase: { sku: string; qty: number };
  refund: { sku: string };
}
 
const shop = new Emitter<ShopEvents>(); // fine now
shop.on("refund", (e) => console.log(e.sku));

4. Factories with constructor types

To accept a class (not an instance) as an argument, type the parameter as something you can call with new:

function create<T>(Ctor: new () => T): T {
  return new Ctor();
}
 
class Logger {
  level = "info";
}
 
const logger = create(Logger);
//    ^? const logger: Logger

new () => T is a construct signature: "something that, called with new and no arguments, produces a T". Passing the class Logger infers T = Logger. Note that the value Logger has type typeof Logger; the instance type is plain Logger.

To forward constructor arguments with full type safety, capture them as a tuple type parameter:

function build<T, A extends unknown[]>(Ctor: new (...args: A) => T, ...args: A): T {
  return new Ctor(...args);
}
 
class Point {
  constructor(public x: number, public y: number) {}
}
 
const p = build(Point, 1, 2);
//    ^? const p: Point
 
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
build(Point, 1, "2");

You'll also see new (...args: any[]) => T in the wild, especially for mixins. It accepts any constructor but checks no arguments; A extends unknown[] is the stricter version when you pass arguments through.

5. pluck and groupBy

Two utilities that appear in almost every take-home. pluck is K extends keyof T again, this time over an array:

function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
 
const orders = [
  { id: 1, total: 9.5, status: "paid" as const },
  { id: 2, total: 20, status: "pending" as const },
];
 
const totals = pluck(orders, "total");
//    ^? const totals: number[]
const statuses = pluck(orders, "status");
//    ^? const statuses: ("paid" | "pending")[]

groupBy buckets items by a key you compute. The honest return type is Partial, because not every possible key is guaranteed to have a bucket:

function groupBy<T, K extends PropertyKey>(items: readonly T[], keyOf: (item: T) => K): Partial<Record<K, T[]>> {
  const groups: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    (groups[keyOf(item)] ??= []).push(item);
  }
  return groups;
}
 
type Order = { id: number; status: "paid" | "pending" | "refunded" };
const orders: Order[] = [
  { id: 1, status: "paid" },
  { id: 2, status: "pending" },
];
 
const byStatus = groupBy(orders, (o) => o.status);
const refunded = byStatus.refunded;
//    ^? const refunded: Order[] | undefined

K is inferred from the callback's return type ("paid" | "pending" | "refunded"), so the buckets have autocomplete. And there are no refunded orders, so undefined is the truth.

Quick check

What is the type of names?

function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
 
const names = pluck([{ name: "Ada", id: 1 }], "name");

6. Builders that accumulate types

Each method call adds to a type parameter, so the final object's type reflects exactly what was set:

class ConfigBuilder<Config extends object = {}> {
  constructor(private readonly config: Config) {}
 
  set<K extends string, V>(key: K, value: V): ConfigBuilder<Config & Record<K, V>> {
    return new ConfigBuilder({ ...this.config, [key]: value } as Config & Record<K, V>);
  }
 
  build(): Config {
    return this.config;
  }
}
 
const config = new ConfigBuilder({}).set("host", "localhost").set("port", 8080).build();
//    ^? const config: Record<"host", string> & Record<"port", number>
 
config.port.toFixed(0);
// @ts-expect-error -- Property 'debug' does not exist on type 'Record<"host", string> & Record<"port", number>'.
config.debug;

Each set returns a new builder whose type parameter is the old one plus the new key. The as is needed because TypeScript types a computed key [key] as a string index, not as K. It's a well-known, contained cast. This pattern powers typed query builders, router definitions and schema libraries.

Try it

Combine two patterns: an emitter whose handlers return Results.

type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
 
type Events = {
  deposit: { amount: number };
  withdraw: { amount: number };
};
 
type Handler<K extends keyof Events> = (payload: Events[K]) => Result<number>;
 
let balance = 100;
 
const handlers: { [K in keyof Events]: Handler<K> } = {
  deposit: ({ amount }) => ({ ok: true, value: (balance += amount) }),
  withdraw: ({ amount }) =>
    amount > balance ? { ok: false, error: "Insufficient funds" } : { ok: true, value: (balance -= amount) },
};
 
function dispatch<K extends keyof Events>(name: K, payload: Events[K]): Result<number> {
  return handlers[name](payload);
}
 
console.log(dispatch("withdraw", { amount: 500 })); // { ok: false, error: "Insufficient funds" }
console.log(dispatch("deposit", { amount: 50 }));   // { ok: true, value: 150 }
 
// Try: add a "transfer" event with a `to: string` field and handle it.

▶ Try it in the TypeScript Playground

Recap

  • Result: a discriminated union { ok: true; value: T } | { ok: false; error: E }; ok()/err() return never on the unused side so they fit any result.
  • Repository: class Repository<T extends Entity>; the constraint lets you use item.id, and Partial<Omit<T, "id">> types patches.
  • Schema-driven store / emitter: K extends keyof Schema plus Schema[K] ties each key to its own value type.
  • Record<string, unknown> constraints reject interfaces (no implicit index signature); use type or extends object.
  • Factories: new () => T accepts a class; new (...args: A) => T with A extends unknown[] forwards arguments type-safely; abstract new accepts abstract classes.
  • pluck returns T[K][]; groupBy should return Partial<Record<K, T[]>>, like Object.groupBy.
  • Builders thread an accumulating type parameter through each call (Config & Record<K, V>).

Interview cards

1 / 7