Back to Insights
TypeScriptPatternsType Safety

TypeScript Patterns That Scale: From Utility Types to Type-Safe APIs

11/202511 min read
Share

TypeScript Is More Than Type Annotations

Most teams use TypeScript for basic type annotations — string, number, interface. But TypeScript's type system is a full-blown programming language capable of expressing constraints that eliminate entire categories of runtime bugs. The patterns in this article move beyond "type the props" into territory where the type system actively prevents impossible states.

These aren't academic exercises. Every pattern here solves a real problem I've encountered repeatedly in production React and Next.js applications.

#1 Discriminated Unions: Eliminating Impossible States

The most impactful TypeScript pattern for UI state management. Instead of a bag of optional properties, you model each state variant explicitly — making it impossible to have a 'loading' state with 'data' or an 'error' state without a message.

Model State Machines with Union Types

types/async-state.ts
// ❌ Loose type: allows impossible combinations interface AsyncState<T> { isLoading: boolean; data?: T; error?: string; } // Nothing prevents: { isLoading: true, data: [...], error: "oops" } // ✅ Discriminated union: each state is explicit type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: string }; // TypeScript enforces correctness at every usage site function renderState<T>(state: AsyncState<T>) { switch (state.status) { case 'idle': return <Placeholder />; case 'loading': return <Spinner />; case 'success': // TypeScript knows 'data' exists here return <DataView data={state.data} />; case 'error': // TypeScript knows 'error' exists here return <ErrorBanner message={state.error} />; } }

The status field acts as a discriminant. TypeScript narrows the type in each branch automatically — no type assertions needed.

Result
  • Impossible states become compile-time errors
  • Exhaustive switch checks catch missing handlers
  • IDE autocompletion shows only valid properties per state

#2 Branded Types: Preventing Value Confusion

TypeScript's structural typing means any string is assignable to any other string type alias. A userId and an orderId are both strings — but accidentally swapping them causes real bugs. Branded types add a phantom property to create nominally-typed values.

Create Branded Primitives

types/branded.ts
// Brand creator utility type Brand<T, B extends string> = T & { readonly __brand: B }; // Define branded types type UserId = Brand<string, 'UserId'>; type OrderId = Brand<string, 'OrderId'>; type Email = Brand<string, 'Email'>; // Smart constructors with runtime validation function createUserId(id: string): UserId { if (!id.startsWith('usr_')) throw new Error('Invalid user ID'); return id as UserId; } function createEmail(value: string): Email { if (!/^[^@]+@[^@]+\.[^@]+$/.test(value)) throw new Error('Invalid email'); return value as Email; } // Now TypeScript prevents mix-ups function getOrder(userId: UserId, orderId: OrderId) { ... } const uid = createUserId('usr_abc123'); const oid = 'ord_xyz789' as OrderId; getOrder(uid, oid); // ✅ Correct getOrder(oid, uid); // ❌ Compile error!
Result
  • Function argument swaps caught at compile time
  • Domain invariants enforced through smart constructors
  • Self-documenting code: types carry semantic meaning

#3 Utility Types for Component APIs

React component props can become complex. TypeScript's built-in utility types and custom mapped types let you derive prop types from data, enforce constraints, and build composable component APIs.

Derive Props from Data

components/DataTable.tsx
// Derive column config from the data shape type ColumnConfig<T> = { [K in keyof T]: { key: K; header: string; render?: (value: T[K], row: T) => React.ReactNode; sortable?: boolean; }; }[keyof T]; interface DataTableProps<T> { data: T[]; columns: ColumnConfig<T>[]; onRowClick?: (row: T) => void; } // Usage: TypeScript knows exactly which keys and value types are valid interface User { id: string; name: string; email: string; role: 'admin' | 'user'; } <DataTable<User> data={users} columns={[ { key: 'name', header: 'Name' }, { key: 'role', header: 'Role', render: (role) => ( // TypeScript knows 'role' is 'admin' | 'user' <Badge variant={role === 'admin' ? 'default' : 'secondary'}> {role} </Badge> )}, ]} />

Polymorphic Components with 'as' Props

components/Box.tsx
import type { ComponentPropsWithoutRef, ElementType } from 'react'; type BoxProps<T extends ElementType> = { as?: T; children: React.ReactNode; } & ComponentPropsWithoutRef<T>; function Box<T extends ElementType = 'div'>({ as, children, ...props }: BoxProps<T>) { const Component = as || 'div'; return <Component {...props}>{children}</Component>; } // Usage: props are type-safe based on the 'as' element <Box as="a" href="/about">Link</Box> // ✅ href is valid <Box as="button" onClick={handle}>Click</Box> // ✅ onClick is valid <Box as="a" onClick={handle}>Bad</Box> // ⚠️ works but suspicious
Result
  • Component APIs are self-documenting and impossible to misuse
  • Column configurations are validated against the actual data shape
  • Polymorphic components carry correct HTML attributes per element

#4 Type-Safe API Layers

API calls are a boundary where type safety often breaks down. You fetch data as 'any' and cast it, losing all guarantees. A type-safe API layer with runtime validation ensures your types match reality.

Zod Schemas as Single Source of Truth

lib/api/schemas.ts
import { z } from 'zod'; // Schema is both the runtime validator AND the type const UserSchema = z.object({ id: z.string(), name: z.string().min(1), email: z.string().email(), role: z.enum(['admin', 'user']), createdAt: z.string().datetime(), }); // Derive the TypeScript type from the schema type User = z.infer<typeof UserSchema>; // Type-safe fetch that validates at runtime async function fetchUser(id: string): Promise<User> { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error('Failed to fetch user'); const data = await res.json(); return UserSchema.parse(data); // If the API response doesn't match, you get a // clear error instead of undefined behavior downstream }
Result
  • Runtime validation catches API contract drift immediately
  • Types are derived from schemas, never duplicated
  • Invalid data throws at the boundary, not deep in component trees

Key Takeaways

  • 1Discriminated unions model state machines that eliminate impossible states at compile time
  • 2Branded types prevent value confusion (userId vs orderId) without runtime overhead in most cases
  • 3Derive component props from data types — never let prop definitions drift from the actual data
  • 4Zod schemas unify runtime validation and TypeScript types into a single source of truth
  • 5The goal isn't more types — it's better types that make wrong code unrepresentable