<< Home

Chapter 10: Advanced Patterns and Performance

Advanced type patterns help you model domain rules faithfully while keeping code ergonomic to use. Performance includes quick compiles, snappy watch mode, and efficient runtime output. This chapter explores nominal typing tricks, robust state encodings, API design for good DX, and compiler options that affect speed.

Branded types

TypeScript is structurally typed; two shapes that match are compatible. Sometimes you want nominal semantics so only values created through a constructor or helper are accepted. A common approach is to intersect with a unique brand that callers cannot forge without a helper.

// brand helpers
declare const Brand: unique symbol;
type BrandOf<T, B extends string> = T & { [Brand]: B };

type UserId = BrandOf<string, 'UserId'>;

function makeUserId(raw: string): UserId {
  // validate format …
  return raw as UserId;
}

function getUser(id: UserId) { /* … */ }

// compile time safety
const s: string = 'abc';
getUser(makeUserId(s));  // ok
// getUser(s);           // error: string is not UserId
💡 Keep the brand on a primitive or small object to avoid large type explosions during inference.

Opaque responses

When parsing untrusted input, return an opaque branded type after validation so downstream code cannot mix raw and parsed values by accident.

type Email = BrandOf<string, 'Email'>;
function parseEmail(s: string): Email | null { return /@/.test(s) ? (s as Email) : null; }

Discriminated unions

Discriminated unions model finite states with exhaustive checks. Use a literal tag field as the discriminator and let the compiler guide you to handle every case.

type LoadState<T> =
  | { tag: 'idle' }
  | { tag: 'loading' }
  | { tag: 'success'; data: T }
  | { tag: 'error'; error: string };

function renderUser(s: LoadState<{ name: string }>) {
  switch (s.tag) {
    case 'idle': return 'Idle';
    case 'loading': return 'Loading';
    case 'success': return s.data.name;
    case 'error': return s.error;
    default: const _exhaustive: never = s; return _exhaustive;
  }
}

Transitions encoded as functions

Keep transitions pure; accept the prior state and an event and return the next state. This mirrors reducer patterns and makes testing straightforward.

type Event =
  | { type: 'start' }
  | { type: 'ok'; data: string }
  | { type: 'fail'; error: string };

function step(s: LoadState<string>, e: Event): LoadState<string> {
  if (e.type === 'start') return { tag: 'loading' };
  if (e.type === 'ok') return { tag: 'success', data: e.data };
  if (e.type === 'fail') return { tag: 'error', error: e.error };
  return s;
}
⚠️ Do not use broad unions without a discriminator. Narrowing by property presence becomes fragile as the model evolves.

API ergonomics and DX considerations

Design APIs that infer types naturally, make common cases short, and advanced cases explicit. Favor option objects for extensibility; provide overloads that preserve inference for function call styles you expect users to prefer.

Options object

Defaults reduce boilerplate while allowing precise control when needed. Keep the return type stable so callers do not fight generics.

interface RetryOptions {
  retries?: number;
  backoffMs?: number;
}

interface Result<T> { ok: true; value: T } | { ok: false; error: string };

async function withRetry<T>(op: () => Promise<T>, opts: RetryOptions = {}): Promise<Result<T>> {
  const { retries = 2, backoffMs = 100 } = opts;
  let lastErr: unknown = null;
  for (let i = 0; i <= retries; i++) {
    try { return { ok: true, value: await op() }; }
    catch (e) { lastErr = e; if (i < retries) await new Promise(r => setTimeout(r, backoffMs)); }
  }
  return { ok: false, error: String(lastErr) };
}

Generics that infer from arguments

Write generic parameters so they flow from inputs. Avoid explicit generics when inference can determine them.

function mapValues<T extends object, R>(obj: T, f: (v: T[keyof T]) => R) {
  const out: Record<keyof T, R> = {} as any;
  for (const k in obj) out[k] = f(obj[k]);
  return out;
}
💡 If inference struggles, add helper functions that fix generic order or use placeholders so callers keep concise call sites.

Compiler options

Compiler and project settings affect how quickly your codebase builds, how accurate type checking is, and the size and shape of emitted JavaScript. Aim for fast feedback in watch mode and reproducible builds for CI.

incremental, composite, and project references

Enable incremental builds to store build info on disk so repeated checks are faster. Use composite projects and references to split a monorepo into leaf packages so each builds independently.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "incremental": true,
    "composite": true,
    "declaration": true,
    "outDir": "dist"
  },
  "references": [{ "path": "./packages/core" }, { "path": "./packages/web" }]
}

skipLibCheck and strictness trade offs

skipLibCheck speeds builds by skipping type checks of dependencies. Keep it on for large projects; keep core strict options enabled for your own code so safety remains high.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true
  }
}

isolatedModules and transform compatibility

isolatedModules ensures each file can be transpiled without cross file information, which aligns with Babel, SWC, and esbuild. This can surface issues early that would otherwise appear only in bundlers.

// tsconfig.json
{ "compilerOptions": { "isolatedModules": true } }

Output targets

Pick a target and module that match your Node version so emitted code is smaller and faster. Avoid downleveling features you do not need.

// Node 20 friendly
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler"
  }
}
⚠️ Changing target can affect helpers and polyfills produced by your transformer. Verify output size and startup time after a change.