Colorful code on a monitor in a dark room

tutorial

TypeScript Patterns That Changed How I Code

Four TypeScript patterns — discriminated unions, template literal types, satisfies, and branded types — that make codebases dramatically safer and clearer.

Alex Rivera 5 min read

TypeScript gets better every major release, but a handful of patterns do the most work in real codebases. These aren’t obscure type gymnastics — they’re techniques I reach for regularly because they eliminate entire categories of bugs at compile time and make code easier to understand at a glance.

Discriminated Unions for State Management

The classic pattern for representing state as booleans leads to impossible combinations:

// The problematic approach — isLoading and error can both be true
interface RequestState {
  isLoading: boolean;
  data: string | null;
  error: Error | null;
}

Discriminated unions enforce that only valid state combinations can exist. The shared literal field — the discriminant — lets TypeScript narrow the type inside conditionals automatically:

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function render(state: RequestState): string {
  switch (state.status) {
    case 'idle':
      return 'Waiting...';
    case 'loading':
      return 'Loading...';
    case 'success':
      // TypeScript knows state.data exists here
      return state.data;
    case 'error':
      // TypeScript knows state.error exists here
      return state.error.message;
  }
}

The exhaustiveness check is free. If you add a new variant to RequestState and forget to handle it in the switch, TypeScript will flag it. No runtime surprises.

Combining with Result Types

You can extend this pattern into a generic Result type that makes error handling explicit across your entire codebase:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parsePositiveInt(input: string): Result<number, string> {
  const n = parseInt(input, 10);
  if (isNaN(n) || n <= 0) {
    return { ok: false, error: `"${input}" is not a positive integer` };
  }
  return { ok: true, value: n };
}

const result = parsePositiveInt('42');
if (result.ok) {
  console.log(result.value * 2); // TypeScript knows value is number
}

Functions that can fail now say so in their type signature. No more reading docs to discover a function might return null.

Template Literal Types

Template literal types let you construct string types with the same syntax as template literal strings. The most practical use is building event or route name systems that stay in sync automatically:

type Entity = 'user' | 'post' | 'comment';
type Action = 'created' | 'updated' | 'deleted';
type EventName = `${Entity}:${Action}`;
// "user:created" | "user:updated" | "user:deleted" | "post:created" | ...

function emit(event: EventName, payload: unknown): void {
  console.log(event, payload);
}

emit('user:created', { id: 1 });  // valid
emit('user:banned', { id: 1 });   // compile error — not a valid EventName

Add a new Entity or Action and the full set of valid event names updates instantly. No string literals scattered across the codebase to maintain.

The satisfies Operator

Before satisfies (added in TypeScript 4.9), you faced an uncomfortable choice when annotating object literals: declare the type explicitly and lose the specific inferred types, or skip the annotation and lose type checking.

type Config = {
  [key: string]: string | number | boolean;
};

// Old way — TypeScript widens the type; palette.primary becomes string | number | boolean
const config: Config = {
  timeout: 5000,
  retries: 3,
  verbose: true,
};

// With satisfies — TypeScript checks against Config but preserves the literal types
const typedConfig = {
  timeout: 5000,
  retries: 3,
  verbose: true,
} satisfies Config;

// typedConfig.timeout is inferred as number, not string | number | boolean
const doubled = typedConfig.timeout * 2; // works without a cast

Where satisfies Shines Most

The pattern is especially useful with route definitions, theme tokens, or any configuration object where you want validation without widening:

type Routes = Record<string, { path: string; auth: boolean }>;

const routes = {
  home: { path: '/', auth: false },
  dashboard: { path: '/dashboard', auth: true },
  profile: { path: '/profile', auth: true },
} satisfies Routes;

// routes.home is still { path: string; auth: boolean }, not Routes[string]
// Autocomplete works on each route key individually

Branded Types for Type-Safe IDs

The most common source of runtime bugs in data-heavy apps is passing the wrong ID to the wrong function. TypeScript’s structural type system treats all string values as equivalent, so userId and postId are interchangeable by default.

Branded types add a phantom tag to a primitive type. The tag exists only at the type level — there’s no runtime overhead — but it makes mixed-up IDs a compile-time error:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

function asUserId(id: string): UserId {
  return id as UserId;
}

function asPostId(id: string): PostId {
  return id as PostId;
}

function getUser(id: UserId): void {
  console.log('fetching user', id);
}

const userId = asUserId('u_123');
const postId = asPostId('p_456');

getUser(userId);  // fine
getUser(postId);  // compile error — PostId is not assignable to UserId

The cast happens once at the boundary where you receive external data (API response, database row). From that point forward, the type system prevents you from mixing up IDs no matter how deep they travel through your codebase.

Putting It Together

These four patterns solve different problems but share a philosophy: push as much as possible into the type system so runtime errors surface during development. Discriminated unions eliminate invalid state. Template literal types keep string contracts consistent. satisfies preserves inference while enforcing shape. Branded types make IDs structurally distinct.

None of them require extra libraries or complex setup. They’re plain TypeScript, and once you start using them, the codebases that don’t start feeling fragile by comparison.

typescript javascript dev-tools