|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/patterns
$cat docs/typescript-—-advanced-design-patterns.md
updated Today·24 min read·published

TypeScript — Advanced Design Patterns

TypeScriptPatternsAdvancedAdvanced🎯Free Tools
Introduction

Advanced TypeScript design patterns encode invariants in the type system: builders that cannot build incomplete objects, Result types that force error handling, exhaustive switches, typed DI, and generic factories.

info

Prefer small, typed patterns over framework magic — they compose and survive refactors.
Builder Pattern
builder.ts
TypeScript
1type BuilderState = {
2 host?: string;
3 port?: number;
4 secure?: boolean;
5};
6
7type Ready = Required<BuilderState>;
8
9class UrlBuilder<S extends BuilderState = {}> {
10 constructor(private readonly state: S = {} as S) {}
11
12 host<H extends string>(host: H): UrlBuilder<S & { host: H }> {
13 return new UrlBuilder({ ...this.state, host });
14 }
15
16 port<P extends number>(port: P): UrlBuilder<S & { port: P }> {
17 return new UrlBuilder({ ...this.state, port });
18 }
19
20 secure(secure = true): UrlBuilder<S & { secure: boolean }> {
21 return new UrlBuilder({ ...this.state, secure });
22 }
23
24 build(this: UrlBuilder<Ready>): string {
25 const scheme = this.state.secure ? "https" : "http";
26 return `${scheme}://${this.state.host}:${this.state.port}`;
27 }
28}
29
30const url = new UrlBuilder().host("api.example.com").port(443).secure().build();
31// new UrlBuilder().host("x").build(); // ERROR — port missing
32
🔥

pro tip

Phantom generics on builders encode required-step workflows without runtime flags.
Result / Either
result.ts
TypeScript
1export type Ok<T> = { ok: true; value: T };
2export type Err<E> = { ok: false; error: E };
3export type Result<T, E = Error> = Ok<T> | Err<E>;
4
5export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
6export const err = <E>(error: E): Err<E> => ({ ok: false, error });
7
8export function map<T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> {
9 return r.ok ? ok(f(r.value)) : r;
10}
11
12export function flatMap<T, U, E>(
13 r: Result<T, E>,
14 f: (t: T) => Result<U, E>,
15): Result<U, E> {
16 return r.ok ? f(r.value) : r;
17}
18
19function parseJson(text: string): Result<unknown, string> {
20 try {
21 return ok(JSON.parse(text));
22 } catch {
23 return err("invalid json");
24 }
25}
26
ApproachProsCons
Result unionExplicit, serializableVerbose
throw/catchFamiliarTypes lose error channel
neverthrow / fp-tsRich APIDependency / learning curve
Exhaustive Switches
exhaustive.ts
TypeScript
1type Shape =
2 | { kind: "circle"; r: number }
3 | { kind: "rect"; w: number; h: number }
4 | { kind: "triangle"; b: number; h: number };
5
6function assertNever(x: never): never {
7 throw new Error(`unexpected: ${JSON.stringify(x)}`);
8}
9
10function area(shape: Shape): number {
11 switch (shape.kind) {
12 case "circle":
13 return Math.PI * shape.r ** 2;
14 case "rect":
15 return shape.w * shape.h;
16 case "triangle":
17 return (shape.b * shape.h) / 2;
18 default:
19 return assertNever(shape);
20 }
21}
22

best practice

Always end discriminated union switches with assertNever — adding a variant becomes a compile error.
Dependency Injection Typing
di.ts
TypeScript
1interface Logger {
2 info(msg: string): void;
3}
4
5interface UserRepo {
6 get(id: string): Promise<User | null>;
7}
8
9interface User { id: string; name: string }
10
11interface AppDeps {
12 logger: Logger;
13 users: UserRepo;
14}
15
16function createGreeter(deps: AppDeps) {
17 return async (id: string) => {
18 const user = await deps.users.get(id);
19 if (!user) {
20 deps.logger.info("missing");
21 return null;
22 }
23 return `Hello ${user.name}`;
24 };
25}
26
27// Tests inject fakes without container magic
28const greet = createGreeter({
29 logger: { info: () => {} },
30 users: { get: async (id) => ({ id, name: "Ada" }) },
31});
32

Constructor injection and factory injection both work; type the dependency bag explicitly so missing deps fail at compile time.

Factory with Generics
factory.ts
TypeScript
1type Ctor<T, A extends unknown[]> = new (...args: A) => T;
2
3function create<T, A extends unknown[]>(
4 Ctor: Ctor<T, A>,
5 ...args: A
6): T {
7 return new Ctor(...args);
8}
9
10class Point {
11 constructor(public x: number, public y: number) {}
12}
13
14const p = create(Point, 1, 2);
15
16// Map of factories
17type EntityMap = {
18 user: { id: string };
19 order: { id: string; total: number };
20};
21
22function entity<K extends keyof EntityMap>(
23 type: K,
24 data: EntityMap[K],
25): EntityMap[K] & { type: K } {
26 return { type, ...data };
27}
28
Typed State Machines
fsm.ts
TypeScript
1type State = "idle" | "loading" | "success" | "error";
2type Event =
3 | { type: "FETCH" }
4 | { type: "RESOLVE" }
5 | { type: "REJECT" }
6 | { type: "RESET" };
7
8type Transition = {
9 [S in State]: {
10 [E in Event["type"]]?: State;
11 };
12};
13
14const transitions = {
15 idle: { FETCH: "loading" },
16 loading: { RESOLVE: "success", REJECT: "error" },
17 success: { RESET: "idle" },
18 error: { RESET: "idle", FETCH: "loading" },
19} as const satisfies Transition;
20
21function next(state: State, event: Event): State {
22 const to = transitions[state][event.type];
23 return to ?? state;
24}
25
Practice Snippets

Snippet 1 — Builder step

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

patterns-1.ts
TypeScript
1new UrlBuilder().host("h").port(80).build(); // needs secure?

Snippet 2 — Result map

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

patterns-2.ts
TypeScript
1map(ok(1), (n) => n + 1);

Snippet 3 — assertNever

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

patterns-3.ts
TypeScript
1default: return assertNever(x);

Snippet 4 — DI bag

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

patterns-4.ts
TypeScript
1createGreeter({ logger, users });

Snippet 5 — Factory ctor

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

patterns-5.ts
TypeScript
1create(Point, 0, 0);

Snippet 6 — Entity map

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

patterns-6.ts
TypeScript
1entity("user", { id: "1" });

Snippet 7 — FSM

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

patterns-7.ts
TypeScript
1next("idle", { type: "FETCH" });

Snippet 8 — Either flatMap

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

patterns-8.ts
TypeScript
1flatMap(ok(1), (n) => ok(String(n)));

Snippet 9 — Exhaustive

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

patterns-9.ts
TypeScript
1type _ = AssertNever<Exclude<Shape["kind"], handled>>;

Snippet 10 — Partial builder

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

patterns-10.ts
TypeScript
1type NeedsPort = UrlBuilder<{ host: string }>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Deep Dive — TypeScript Design Patterns

This deep dive expands TypeScript Design Patterns with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Branded Result

Recipe 1: Branded Result. Adapt names to your domain; keep the type relationships intact.

branded-result.ts
TypeScript
1type ParseError = { code: "PARSE" };
2type UserId = string & { __b: "UserId" };
3type R = Result<UserId, ParseError>;
4
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Pipe helper

Recipe 2: Pipe helper. Adapt names to your domain; keep the type relationships intact.

pipe.ts
TypeScript
1function pipe<A>(a: A): A;
2function pipe<A, B>(a: A, ab: (a: A) => B): B;
3function pipe(a: unknown, ...fns: Function[]) {
4 return fns.reduce((x, f) => f(x), a);
5}
6
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Specification

Recipe 3: Specification. Adapt names to your domain; keep the type relationships intact.

spec.ts
TypeScript
1interface Spec<T> { isSatisfiedBy(t: T): boolean }
2const and = <T>(...s: Spec<T>[]): Spec<T> => ({
3 isSatisfiedBy: (t) => s.every((x) => x.isSatisfiedBy(t)),
4});
5
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Typed event emitter

Recipe 4: Typed event emitter. Adapt names to your domain; keep the type relationships intact.

emitter.ts
TypeScript
1type Events = { ready: void; data: { n: number } };
2class Emitter<E extends Record<string, unknown>> {
3 on<K extends keyof E>(e: K, cb: (p: E[K]) => void) {}
4 emit<K extends keyof E>(...args: E[K] extends void ? [K] : [K, E[K]]) {}
5}
6
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

Does this replace runtime checks?

No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.

How do I migrate gradually?

Enable strict flags one at a time, fix a package, then expand. See the migration guide.

What about performance?

Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.

QuestionShort answer
Runtime cost of brands/variance annotations?Zero — compile-time only
Need emit?Often noEmit / bundler handles emit
CI gate?tsc --noEmit + ESLint type-checked
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered TypeScript Design Patterns end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Deep Dive — patterns

This deep dive expands patterns with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Core pattern

Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.

patterns-a.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Boundary parse

Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.

patterns-b.ts
TypeScript
1export function parse(input: unknown): string {
2 if (typeof input !== "string") throw new Error("string");
3 return input;
4}
5
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Exhaustive

Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.

patterns-c.ts
TypeScript
1function assertNever(x: never): never { throw new Error(String(x)); }
2type K = "a" | "b";
3export function f(k: K) {
4 switch (k) {
5 case "a": return 1;
6 case "b": return 2;
7 default: return assertNever(k);
8 }
9}
10
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Readonly params

Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.

patterns-d.ts
TypeScript
1export function sum(nums: readonly number[]): number {
2 return nums.reduce((a, b) => a + b, 0);
3}
4
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

Does this replace runtime checks?

No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.

How do I migrate gradually?

Enable strict flags one at a time, fix a package, then expand. See the migration guide.

What about performance?

Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.

QuestionShort answer
Runtime cost of brands/variance annotations?Zero — compile-time only
Need emit?Often noEmit / bundler handles emit
CI gate?tsc --noEmit + ESLint type-checked
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered patterns end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
Worked Example

End-to-end worked example for patterns. Copy into a scratch project with strict: true.

patterns-worked.ts
TypeScript
1export type Flag = "on" | "off";
2export function toggle(f: Flag): Flag {
3 return f === "on" ? "off" : "on";
4}
5

Step-by-step

1. Paste the snippet. 2. Introduce a deliberate type error. 3. Fix it without assertions. 4. Add a second consumer that should fail if variance/brands/refs are wrong.

5. Run npx tsc --noEmit. 6. Commit the learning as a unit test or type test with @ts-expect-error.

info

If the example feels large, extract types into a dedicated file and keep runtime logic thin.
StepPass criteria
Compiles under strictNo errors
Intentional misuse fails@ts-expect-error lights up
Runtime path testedVitest or node assert
Docs updatedREADME / PR note
patterns-type-test.ts
TypeScript
1import type { Expect, Equal } from "./type-tests";
2
3// Local helpers if you lack a type-test lib:
4type Equal<A, B> =
5 (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
6type Expect<T extends true> = T;
7
8type _smoke = Expect<Equal<true, true>>;
9// Topic: patterns
10

best practice

Keep type tests in CI alongside unit tests — they are documentation that cannot rot silently.
Production Notes

Shipping notes teams hit in production when adopting these patterns: version skew between editor TypeScript and CI, incomplete include globs, and silent any from third-party DefinitelyTyped stubs.

verify.sh
Bash
1npx tsc --noEmit
2npx eslint .
3git diff --exit-code # ensure no accidental emit
4

warning

Never commit skipLibCheck: false flips without measuring CI time — but do not use skipLibCheck to hide broken local types.

Document peer dependency TypeScript ranges for libraries. For apps, pin the TypeScript version in the workspace and enable the workspace SDK in VS Code/Cursor.

package-engines.json
JSON
1{
2 "devDependencies": {
3 "typescript": "~5.8.0"
4 },
5 "packageManager": "pnpm@9"
6}
7

Rollback plan

If a strict flag floods errors, revert the flag, keep fixed files, and re-enable on a smaller glob via a nested tsconfig. Progress should be monotonic even when flags temporarily retreat.

🔥

pro tip

Track error counts in CI comments so migrations stay visible to the whole team.
$Blueprint — Engineering Documentation·Section ID: TS-PATTERNS·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.