TypeScript Patterns for Large React Codebases
TypeScript Patterns for Large React CodebasesJanuary 30, 2026

TypeScript Patterns for Large React Codebases

Practical TypeScript habits that keep a growing React app refactorable: domain types, API boundaries, discriminated unions, and how to stop any from spreading.

Table of Contents

  1. Type the Domain, Not the JSX
  2. API Responses as a Boundary
  3. Discriminated Unions for UI State
  4. Component Props That Age Well
  5. Narrowing Without Noise
  6. Gradual Strictness

Type the Domain, Not the JSX {#domain}

Most TypeScript pain in React apps is not missing FC<>. It is untyped money, users, and orders flowing through the UI as Record<string, any>.

type Money = {
  amount: number;
  currency: 'USD' | 'EUR';
};

type ProductId = string & { readonly brand: unique symbol };

type Product = {
  id: ProductId;
  slug: string;
  name: string;
  price: Money;
  status: 'draft' | 'active' | 'archived';
};

Branded IDs stop you from passing a cart ID into getProduct. Money as an object stops you from adding usd to cents by accident.

Keep these types in src/types or src/domain, not inside a random component file.

API Responses as a Boundary {#api}

Network data is untrusted. Parse it once, then let the rest of the app use domain types.

import { z } from 'zod';

const ProductResponse = z.object({
  id: z.string(),
  slug: z.string(),
  name: z.string(),
  price_cents: z.number().int(),
  currency: z.enum(['USD', 'EUR']),
  status: z.enum(['draft', 'active', 'archived']),
});

export function toProduct(raw: unknown): Product {
  const data = ProductResponse.parse(raw);
  return {
    id: data.id as ProductId,
    slug: data.slug,
    name: data.name,
    price: { amount: data.price_cents / 100, currency: data.currency },
    status: data.status,
  };
}

When the backend renames price_cents, one parser fails. Twelve screens do not silently show $NaN.

Discriminated Unions for UI State {#unions}

Async UI is not { data, loading, error } with all three optional.

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

function ProductPanel({ state }: { state: AsyncState<Product> }) {
  switch (state.status) {
    case 'idle':
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage error={state.error} />;
    case 'success':
      return <h1>{state.data.name}</h1>;
  }
}

TypeScript will not let you read data on the loading branch. That is the feature.

The same pattern works for checkout steps: 'cart' | 'shipping' | 'payment' | 'review' with step-specific fields.

Component Props That Age Well {#props}

Prefer explicit props over spreading the entire domain object into a dumb component.

type PriceTagProps = {
  price: Money;
  strike?: Money;
  size?: 'sm' | 'md';
};

export function PriceTag({ price, strike, size = 'md' }: PriceTagProps) {
  return (
    <span data-size={size}>
      {strike ? <s>{formatMoney(strike)}</s> : null}
      {formatMoney(price)}
    </span>
  );
}

If PriceTag needs Product, every Storybook story needs a fake product. If it needs Money, stories stay small.

Use ComponentPropsWithoutRef<'button'> to inherit native attributes instead of re-declaring onClick and disabled.

Narrowing Without Noise {#narrowing}

function isActive(product: Product): product is Product & { status: 'active' } {
  return product.status === 'active';
}

const buyable = products.filter(isActive);

For optional chaining, prefer early returns in event handlers. Deep foo?.bar?.baz usually means the type is wrong one layer up.

Avoid as except at the system edge (URL params, JSON, branded IDs). If you need as in render, the prop type is lying.

Gradual Strictness {#strict}

Turning on strict in a Vue-to-React migration in week one can freeze the team. Sequence it:

  1. strictNullChecks on new files
  2. Ban any via ESLint in src/features/**
  3. noUncheckedIndexedAccess once lists are typed
  4. Delete leftover // @ts-nocheck
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

TypeScript is a communication tool. The types should describe the business so the next engineer can change checkout without holding the whole store in their head.