Phase 4 · Generics · Lesson 4.3
IntermediateGeneric 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 = Erroris a default, soResult<number>means "a number, or anError".okreturnsResult<T, never>.neveris assignable to everything, soResult<number, never>fits into anyResult<number, E>. Same forerrwithResult<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 | undefinedThe 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 Eventsrestricts the name to known events, and keeps it as a literal ("login"), notstring.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 CSpot 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: Loggernew () => 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[] | undefinedK 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()returnneveron the unused side so they fit any result. - Repository:
class Repository<T extends Entity>; the constraint lets you useitem.id, andPartial<Omit<T, "id">>types patches. - Schema-driven store / emitter:
K extends keyof SchemaplusSchema[K]ties each key to its own value type. Record<string, unknown>constraints reject interfaces (no implicit index signature); usetypeorextends object.- Factories:
new () => Taccepts a class;new (...args: A) => TwithA extends unknown[]forwards arguments type-safely;abstract newaccepts abstract classes. - pluck returns
T[K][]; groupBy should returnPartial<Record<K, T[]>>, likeObject.groupBy. - Builders thread an accumulating type parameter through each call (
Config & Record<K, V>).
Interview cards
1 / 7