The React codebase I inherited on a client project had 100% TypeScript coverage and roughly zero type safety: any on every API response, as casts everywhere, and props typed as object. TypeScript was slowing them down without catching anything. The patterns below are what I introduced over the following months; each one removed a class of bug we were seeing in production.

1. Discriminated unions for state

The most common React bug I fix is an impossible state: isLoading and error both true, or data undefined while isSuccess is true. The fix is to make the shape of the state say which case you are in.

// Before: four booleans, sixteen combinations, four of them valid
interface Bad { isLoading: boolean; isError: boolean; data?: Order[]; error?: string; }

// After: exactly the valid states, and the compiler knows which fields exist in each
type OrdersState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Order[] }
  | { status: 'error'; error: string; retry: () => void };

function OrdersView({ state }: { state: OrdersState }) {
  switch (state.status) {
    case 'idle':    return <p>Search for orders above.</p>;
    case 'loading': return <Spinner />;
    case 'success': return <OrderTable rows={state.data} />;          // data is Order[] here, guaranteed
    case 'error':   return <ErrorBox message={state.error} onRetry={state.retry} />;
    default: {
      const _exhaustive: never = state;                               // compile error if a case is added later
      return _exhaustive;
    }
  }
}

The never trick at the bottom is the part people skip. When someone adds status: 'cancelled' next year, every switch that forgot to handle it fails to compile. That is the compiler doing code review.

2. Validate at the boundary, infer everything else

API responses, form input, URL params, localStorage, environment variables: anything from outside your process is unknown until proven otherwise. Zod turns a schema into both a runtime validator and a static type, so you define the shape once.

// lib/api/orders.ts
import { z } from 'zod';

export const Order = z.object({
  id: z.string().uuid(),
  customer: z.object({ name: z.string(), email: z.string().email() }),
  totalCents: z.number().int().nonnegative(),
  status: z.enum(['pending', 'paid', 'shipped', 'refunded']),
  createdAt: z.coerce.date(),                          // "2026-06-10T09:00:00Z" → Date
});
export type Order = z.infer<typeof Order>;            // the TS type, derived, never drifts

const OrdersResponse = z.object({ items: z.array(Order), nextCursor: z.string().nullable() });

export async function fetchOrders(cursor?: string) {
  const res = await fetch(`/api/orders?cursor=${cursor ?? ''}`);
  if (!res.ok) throw new ApiError(res.status, await res.text());
  return OrdersResponse.parse(await res.json());     // throws a readable error if the API changed shape
}

When the backend team renamed total_cents to totalCents on that project, the old code showed $NaN on the orders page for two days before anyone noticed. With the schema, the deploy failed the first integration test with Expected number, received undefined at items[0].totalCents. Same pattern on the server side: my production REST API guide uses Zod on every request body.

Environment variables too

// lib/env.ts  -- crashes at startup, not at 3am when the first request needs the key
const Env = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET: z.string().startsWith('sk_'),
  NEXT_PUBLIC_SITE_URL: z.string().url(),
});
export const env = Env.parse(process.env);

3. Generic components with constraints

A data table that works for orders, customers and products, without any and with column definitions that are checked against the row type:

// components/DataTable.tsx
import type { ReactNode } from 'react';

export interface Column<T> {
  key: keyof T & string;                 // must be a real field of T
  header: string;
  render?: (value: T[keyof T], row: T) => ReactNode;
  width?: string;
}

interface Props<T extends { id: string }> {
  rows: T[];
  columns: Column<T>[];
  onRowClick?: (row: T) => void;
  emptyMessage?: string;
}

export function DataTable<T extends { id: string }>({ rows, columns, onRowClick, emptyMessage = 'Nothing to show' }: Props<T>) {
  if (rows.length === 0) return <p className="text-slate-500">{emptyMessage}</p>;
  return (
    <table>
      <thead><tr>{columns.map(c => <th key={c.key} style={{ width: c.width }}>{c.header}</th>)}</tr></thead>
      <tbody>
        {rows.map(row => (
          <tr key={row.id} onClick={onRowClick ? () => onRowClick(row) : undefined}>
            {columns.map(c => <td key={c.key}>{c.render ? c.render(row[c.key], row) : String(row[c.key])}</td>)}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// Usage: a typo in `key` or a render function expecting the wrong type is a compile error.
<DataTable
  rows={orders}
  columns={[
    { key: 'id', header: 'Order' },
    { key: 'totalCents', header: 'Total', render: v => formatCurrency(v as number) },
    { key: 'status', header: 'Status', render: v => <StatusBadge status={v as Order['status']} /> },
  ]}
/>

The constraint T extends { id: string } is what lets the component use row.id as a key without knowing anything else about T. Constrain generics to exactly what the component needs and nothing more.

4. Branded types for IDs

Every ID in the inherited codebase was a string. Passing a user ID to a function expecting an order ID compiled fine and returned an empty result in production. Branded (nominal) types fix this with no runtime cost:

// lib/ids.ts
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

export type UserId  = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;

// The only way to make one is through a constructor that validates.
export const UserId  = (s: string): UserId  => { if (!isUuid(s)) throw new TypeError('bad user id');  return s as UserId; };
export const OrderId = (s: string): OrderId => { if (!isUuid(s)) throw new TypeError('bad order id'); return s as OrderId; };

function getOrder(id: OrderId) { /* ... */ }

const uid = UserId('7f3e…');
getOrder(uid);           // ✗ Type 'UserId' is not assignable to type 'OrderId'
getOrder(OrderId('…'));  // ✓

Zod supports this directly with z.string().uuid().brand<'OrderId'>(), so the API schemas from pattern 2 can emit branded IDs automatically.

5. Props patterns that scale

Use satisfies for config objects

const routes = {
  home: '/',
  order: (id: OrderId) => `/orders/${id}`,
  settings: '/settings',
} satisfies Record<string, string | ((...a: any[]) => string)>;

routes.order(OrderId('…'));   // still knows the exact function type, unlike a plain annotation

Derive prop types from existing elements

import type { ComponentPropsWithoutRef } from 'react';

// A Button that accepts everything a <button> does, plus our variant.
type ButtonProps = ComponentPropsWithoutRef<'button'> & { variant?: 'primary' | 'ghost'; loading?: boolean };

export function Button({ variant = 'primary', loading, children, disabled, ...rest }: ButtonProps) {
  return <button {...rest} disabled={disabled || loading} data-variant={variant}>{loading ? <Spinner /> : children}</button>;
}

Mutually exclusive props

// Either `href` (renders <a>) or `onClick` (renders <button>), never both, never neither.
type LinkLike   = { href: string; onClick?: never };
type ButtonLike = { onClick: () => void; href?: never };
type ActionProps = (LinkLike | ButtonLike) & { label: string };

6. The tsconfig that catches real bugs

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,        // arr[0] is T | undefined; forces a check
    "exactOptionalPropertyTypes": true,      // { a?: string } rejects { a: undefined }
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "useUnknownInCatchVariables": true,      // catch (e) is unknown, not any
    "verbatimModuleSyntax": true,            // forces `import type`, keeps bundles clean
    "moduleResolution": "bundler",
    "target": "ES2022",
    "jsx": "react-jsx",
    "skipLibCheck": true
  }
}

noUncheckedIndexedAccess generates the most noise on adoption and finds the most bugs: every items[0].name on a possibly-empty array is now an error. On the inherited project it flagged eleven places, four of which were live crash reports in Sentry.

Anti-patterns to delete on sight

PatternProblemReplace with
as any, as unknown as TTurns off the compiler exactly where you need itZod parse, type guard, or fix the type
! non-null assertionsRuntime crash waiting to happenEarly return, ?., or narrow the union
React.FC<Props>Implicit children, awkward genericsPlain function with typed props
enumRuntime object, odd numeric behaviourUnion of string literals or as const object
Hand-written API typesDrift from the real APIInfer from Zod or generate from OpenAPI/GraphQL

None of these patterns need a library beyond Zod, and together they change TypeScript from a documentation tool into a bug-catching tool. If you are choosing an API layer to pair with this, GraphQL vs REST vs tRPC covers how end-to-end types affect that choice; tRPC in particular makes patterns 2 and 4 almost free.