All posts

February 10, 2026

TypeScript Patterns I Actually Use

Practical TypeScript patterns from building Next.js applications — not type gymnastics, just the things that quietly make codebases easier to work with.

TypeScript Patterns I Actually Use

Context

I write TypeScript primarily on the frontend — Next.js, React, API integration layers. These are patterns I reach for repeatedly, not because they're clever, but because they've prevented real bugs and made refactoring safer.

Discriminated Unions for Async State

The temptation is to model async state with optional fields:

// Fragile — what does { loading: false, data: undefined, error: undefined } mean?
interface AsyncState<T> {
  loading: boolean;
  data?: T;
  error?: Error;
}

Discriminated unions make impossible combinations unrepresentable:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

TypeScript narrows correctly inside a switch, so you get compile-time safety and full autocomplete on data only when status === 'success'. I use this pattern for every data-fetching hook in the Parrot Interview platform, where the UI has genuinely different shapes for each state.

satisfies for Config Objects

satisfies validates a value against a type without widening it — you get the validation of a type annotation but keep the literal type:

const routes = {
  home: '/',
  dashboard: '/dashboard',
  settings: '/settings',
} satisfies Record<string, string>;

// routes.home is '/' (literal), not string
// Autocomplete works. Typos get caught at compile time.

I use this for route maps, icon name registries, and any config object where I want to reference specific keys by name later.

Typed API Responses with zod

When integrating with backend APIs — especially the Spring Boot services I've worked with — runtime validation matters as much as compile-time types. zod schemas serve as both:

import { z } from 'zod';

const CandidateSchema = z.object({
  id: z.string(),
  name: z.string(),
  score: z.number().min(0).max(100),
  status: z.enum(['pending', 'evaluated', 'rejected']),
});

type Candidate = z.infer<typeof CandidateSchema>;

async function fetchCandidate(id: string): Promise<Candidate> {
  const res = await fetch(`/api/candidates/${id}`);
  const data = await res.json();
  return CandidateSchema.parse(data); // throws if the shape is wrong
}

The schema is the single source of truth. The type is derived from it, so they can never drift apart. When the backend changes a field name and forgets to tell you, this throws at the boundary instead of silently producing undefined somewhere deep in the UI.

Branded Types for IDs

At MokaHR, mixing candidate IDs, job IDs, and interview IDs in the same function signatures was a source of subtle bugs. Branded types make the compiler catch them:

type CandidateId = string & { readonly _brand: 'CandidateId' };
type InterviewId = string & { readonly _brand: 'InterviewId' };

function scheduleInterview(candidateId: CandidateId, interviewId: InterviewId) { /* ... */ }

// scheduleInterview(interviewId, candidateId) — compile error, not a runtime surprise

The branding exists only in the type system — zero runtime overhead. Use a small helper to create branded values at trust boundaries (API responses, URL params):

function asCandidateId(id: string): CandidateId {
  return id as CandidateId;
}

ReturnType and Awaited for Derived Types

When a function's return type is complex, derive dependent types from it rather than re-declaring them. This keeps things in sync automatically:

async function getInterviewReport(id: string) {
  // ... complex return shape
}

type InterviewReport = Awaited<ReturnType<typeof getInterviewReport>>;

If the function signature changes, every type derived from it updates automatically. No manual sync, no drift.

When to Stop

Advanced types are a tool, not a goal. If a type definition takes more than a few seconds to parse, it's a maintenance liability. The patterns above earn their complexity because they prevent real, recurring bugs. Most of the more exotic TypeScript tricks don't clear that bar.

The right amount of type sophistication is the minimum that makes the next refactor safer.