TypeScript — Advanced Design Patterns
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
| 1 | type BuilderState = { |
| 2 | host?: string; |
| 3 | port?: number; |
| 4 | secure?: boolean; |
| 5 | }; |
| 6 | |
| 7 | type Ready = Required<BuilderState>; |
| 8 | |
| 9 | class 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 | |
| 30 | const url = new UrlBuilder().host("api.example.com").port(443).secure().build(); |
| 31 | // new UrlBuilder().host("x").build(); // ERROR — port missing |
| 32 |
pro tip
| 1 | export type Ok<T> = { ok: true; value: T }; |
| 2 | export type Err<E> = { ok: false; error: E }; |
| 3 | export type Result<T, E = Error> = Ok<T> | Err<E>; |
| 4 | |
| 5 | export const ok = <T>(value: T): Ok<T> => ({ ok: true, value }); |
| 6 | export const err = <E>(error: E): Err<E> => ({ ok: false, error }); |
| 7 | |
| 8 | export 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 | |
| 12 | export 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 | |
| 19 | function parseJson(text: string): Result<unknown, string> { |
| 20 | try { |
| 21 | return ok(JSON.parse(text)); |
| 22 | } catch { |
| 23 | return err("invalid json"); |
| 24 | } |
| 25 | } |
| 26 |
| Approach | Pros | Cons |
|---|---|---|
| Result union | Explicit, serializable | Verbose |
| throw/catch | Familiar | Types lose error channel |
| neverthrow / fp-ts | Rich API | Dependency / learning curve |
| 1 | type Shape = |
| 2 | | { kind: "circle"; r: number } |
| 3 | | { kind: "rect"; w: number; h: number } |
| 4 | | { kind: "triangle"; b: number; h: number }; |
| 5 | |
| 6 | function assertNever(x: never): never { |
| 7 | throw new Error(`unexpected: ${JSON.stringify(x)}`); |
| 8 | } |
| 9 | |
| 10 | function 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
| 1 | interface Logger { |
| 2 | info(msg: string): void; |
| 3 | } |
| 4 | |
| 5 | interface UserRepo { |
| 6 | get(id: string): Promise<User | null>; |
| 7 | } |
| 8 | |
| 9 | interface User { id: string; name: string } |
| 10 | |
| 11 | interface AppDeps { |
| 12 | logger: Logger; |
| 13 | users: UserRepo; |
| 14 | } |
| 15 | |
| 16 | function 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 |
| 28 | const 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.
| 1 | type Ctor<T, A extends unknown[]> = new (...args: A) => T; |
| 2 | |
| 3 | function create<T, A extends unknown[]>( |
| 4 | Ctor: Ctor<T, A>, |
| 5 | ...args: A |
| 6 | ): T { |
| 7 | return new Ctor(...args); |
| 8 | } |
| 9 | |
| 10 | class Point { |
| 11 | constructor(public x: number, public y: number) {} |
| 12 | } |
| 13 | |
| 14 | const p = create(Point, 1, 2); |
| 15 | |
| 16 | // Map of factories |
| 17 | type EntityMap = { |
| 18 | user: { id: string }; |
| 19 | order: { id: string; total: number }; |
| 20 | }; |
| 21 | |
| 22 | function entity<K extends keyof EntityMap>( |
| 23 | type: K, |
| 24 | data: EntityMap[K], |
| 25 | ): EntityMap[K] & { type: K } { |
| 26 | return { type, ...data }; |
| 27 | } |
| 28 |
| 1 | type State = "idle" | "loading" | "success" | "error"; |
| 2 | type Event = |
| 3 | | { type: "FETCH" } |
| 4 | | { type: "RESOLVE" } |
| 5 | | { type: "REJECT" } |
| 6 | | { type: "RESET" }; |
| 7 | |
| 8 | type Transition = { |
| 9 | [S in State]: { |
| 10 | [E in Event["type"]]?: State; |
| 11 | }; |
| 12 | }; |
| 13 | |
| 14 | const 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 | |
| 21 | function next(state: State, event: Event): State { |
| 22 | const to = transitions[state][event.type]; |
| 23 | return to ?? state; |
| 24 | } |
| 25 |
Snippet 1 — Builder step
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | new 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.
| 1 | map(ok(1), (n) => n + 1); |
Snippet 3 — assertNever
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | default: return assertNever(x); |
Snippet 4 — DI bag
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | createGreeter({ logger, users }); |
Snippet 5 — Factory ctor
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | create(Point, 0, 0); |
Snippet 6 — Entity map
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | entity("user", { id: "1" }); |
Snippet 7 — FSM
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | next("idle", { type: "FETCH" }); |
Snippet 8 — Either flatMap
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | flatMap(ok(1), (n) => ok(String(n))); |
Snippet 9 — Exhaustive
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type _ = AssertNever<Exclude<Shape["kind"], handled>>; |
Snippet 10 — Partial builder
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | type NeedsPort = UrlBuilder<{ host: string }>; |
note
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
Recipe 1: Branded Result. Adapt names to your domain; keep the type relationships intact.
| 1 | type ParseError = { code: "PARSE" }; |
| 2 | type UserId = string & { __b: "UserId" }; |
| 3 | type R = Result<UserId, ParseError>; |
| 4 |
note
Recipe 2: Pipe helper. Adapt names to your domain; keep the type relationships intact.
| 1 | function pipe<A>(a: A): A; |
| 2 | function pipe<A, B>(a: A, ab: (a: A) => B): B; |
| 3 | function pipe(a: unknown, ...fns: Function[]) { |
| 4 | return fns.reduce((x, f) => f(x), a); |
| 5 | } |
| 6 |
note
Recipe 3: Specification. Adapt names to your domain; keep the type relationships intact.
| 1 | interface Spec<T> { isSatisfiedBy(t: T): boolean } |
| 2 | const and = <T>(...s: Spec<T>[]): Spec<T> => ({ |
| 3 | isSatisfiedBy: (t) => s.every((x) => x.isSatisfiedBy(t)), |
| 4 | }); |
| 5 |
note
Recipe 4: Typed event emitter. Adapt names to your domain; keep the type relationships intact.
| 1 | type Events = { ready: void; data: { n: number } }; |
| 2 | class 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
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.
| Question | Short 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 |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
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
This deep dive expands patterns with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.
info
Recipe 1: Core pattern. Adapt names to your domain; keep the type relationships intact.
| 1 | export type Flag = "on" | "off"; |
| 2 | export function toggle(f: Flag): Flag { |
| 3 | return f === "on" ? "off" : "on"; |
| 4 | } |
| 5 |
note
Recipe 2: Boundary parse. Adapt names to your domain; keep the type relationships intact.
| 1 | export function parse(input: unknown): string { |
| 2 | if (typeof input !== "string") throw new Error("string"); |
| 3 | return input; |
| 4 | } |
| 5 |
note
Recipe 3: Exhaustive. Adapt names to your domain; keep the type relationships intact.
| 1 | function assertNever(x: never): never { throw new Error(String(x)); } |
| 2 | type K = "a" | "b"; |
| 3 | export 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
Recipe 4: Readonly params. Adapt names to your domain; keep the type relationships intact.
| 1 | export function sum(nums: readonly number[]): number { |
| 2 | return nums.reduce((a, b) => a + b, 0); |
| 3 | } |
| 4 |
note
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.
| Question | Short 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 |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered patterns end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
End-to-end worked example for patterns. Copy into a scratch project with strict: true.
| 1 | export type Flag = "on" | "off"; |
| 2 | export 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
| Step | Pass criteria |
|---|---|
| Compiles under strict | No errors |
| Intentional misuse fails | @ts-expect-error lights up |
| Runtime path tested | Vitest or node assert |
| Docs updated | README / PR note |
| 1 | import type { Expect, Equal } from "./type-tests"; |
| 2 | |
| 3 | // Local helpers if you lack a type-test lib: |
| 4 | type Equal<A, B> = |
| 5 | (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false; |
| 6 | type Expect<T extends true> = T; |
| 7 | |
| 8 | type _smoke = Expect<Equal<true, true>>; |
| 9 | // Topic: patterns |
| 10 |
best practice
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.
| 1 | npx tsc --noEmit |
| 2 | npx eslint . |
| 3 | git diff --exit-code # ensure no accidental emit |
| 4 |
warning
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.
| 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
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.