← Blog
TypeScript

TypeScript Patterns That Keep Large React Codebases Maintainable

By Muzamal Ali6 min readTypeScript · React · Architecture
TypeScript Patterns That Keep Large React Codebases Maintainable — TypeScript article by Muzamal Ali

Types are a communication tool

In a large React codebase, TypeScript's job is not catching typos — it is encoding decisions so the next developer cannot misuse what you built. The patterns below come from production apps maintained by teams of four to nine developers, where "the next developer" arrives every month.

Model your domain once, derive everything else

The root mistake in big codebases is re-declaring the same shape in five places. Define domain types once, close to the API boundary, and derive variations instead of redeclaring them:

interface Patient {
  id: string;
  name: string;
  admittedAt: string;
  ward: Ward;
}

type PatientSummary = Pick<Patient, "id" | "name" | "ward">;
type PatientDraft = Omit<Patient, "id" | "admittedAt">;

When the API changes, one edit propagates. Pick, Omit, and Partial are not advanced features — they are the difference between one source of truth and five stale copies.

The discipline that makes this hold is putting the domain type next to the function that produces it, so nobody is tempted to redeclare it at the call site:

// features/patients/types/patient.ts
export type Ward = "icu" | "maternity" | "general" | "paediatrics";

export interface Patient {
  id: string;
  name: string;
  mrn: string;
  admittedAt: string;
  ward: Ward;
  dischargedAt: string | null;
}

// Derived views — never redeclared, always traceable back to Patient.
export type PatientSummary = Pick<Patient, "id" | "name" | "mrn" | "ward">;
export type PatientDraft = Omit<Patient, "id" | "admittedAt" | "dischargedAt">;
export type PatientUpdate = Partial<PatientDraft>;

// A derived state, computed once rather than stored and allowed to drift.
export function isAdmitted(patient: Patient): boolean {
  return patient.dischargedAt === null;
}

Note dischargedAt: string | null rather than an optional dischargedAt?: string. The distinction matters: optional means "this field may be absent from the object", null means "the value exists and is deliberately empty". Conflating them is how you end up unable to tell a patient who has not been discharged from an API response that simply forgot to include the field.

Discriminated unions for UI state

Booleans multiply into impossible states: isLoading && isError should not be representable. Discriminated unions make invalid states unrepresentable and force every consumer to handle every case:

// features/patients/types/fetchState.ts
export type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string; retryable: boolean };

/**
 * Called in the default branch of an exhaustive switch. If a new variant is
 * added to the union and a switch forgets it, this fails to compile — the
 * unhandled variant is no longer assignable to never.
 */
export function assertNever(value: never): never {
  throw new Error("Unhandled variant: " + JSON.stringify(value));
}

The payoff is at the render site, where the compiler now guarantees every state has a UI and narrows the type inside each branch:

// features/patients/components/PatientPanel.tsx
import { assertNever, type FetchState } from "../types/fetchState";
import type { PatientSummary } from "../types/patient";

export function PatientPanel({ state }: { state: FetchState<PatientSummary[]> }) {
  switch (state.status) {
    case "idle":
      return <p>Select a ward to view patients.</p>;

    case "loading":
      return <PatientTableSkeleton rows={8} />;

    case "error":
      // state.message and state.retryable are available here; state.data is not.
      return <ErrorNotice message={state.message} showRetry={state.retryable} />;

    case "success":
      // state.data is PatientSummary[] here, with no optional chaining needed.
      return <PatientTable patients={state.data} />;

    default:
      return assertNever(state);
  }
}

Every switch over status is now exhaustively checked by the compiler. On team projects this single pattern eliminated a recurring family of "spinner and error shown together" bugs.

The second benefit shows up months later. When someone adds a { status: "partial"; data: T; staleSince: string } variant for cached-but-outdated results, the build fails at every component that renders this union — which is exactly the list of files that need a decision. Without the union, that same change ships silently and each screen falls through to whichever branch happened to be last.

Component props: strict at the boundary

Public, shared components deserve strict prop types — no any, no optional-everything. I type callbacks precisely (onSelect: (id: string) => void, never Function), use unions for variants instead of free strings, and let children be ReactNode only when the component genuinely accepts anything. Internal one-off components can be looser; the shared ui layer cannot.

Generics where they earn their keep

A generic data-table or form-field hook removes dozens of casts across a codebase. But generics on everything makes simple code unreadable. My bar: introduce a generic when at least two call sites would otherwise cast or duplicate. Below that, concrete types read faster.

Which tsconfig settings actually earn their strictness?

Strictness erodes through config, not code. strict: true is the floor; noUncheckedIndexedAccess catches a real class of runtime errors on array and record access; and a lint rule banning bare any (with documented escape hatches) keeps the pressure on. Adopting these on an existing codebase works best incrementally — directory by directory — rather than one heroic migration PR.

This is the compiler section I carry between projects, with the reason each flag is present:

{
  "compilerOptions": {
    // The floor. Enables strictNullChecks, noImplicitAny, strictFunctionTypes
    // and friends — everything below is what strict does NOT cover.
    "strict": true,

    // arr[0] is T | undefined, not T. Catches the single most common runtime
    // crash in React code: reading .name off an empty-array lookup.
    "noUncheckedIndexedAccess": true,

    // Forces an explicit override keyword on methods that override a base.
    // Low value in hooks-era React, high value if you still have classes.
    "noImplicitOverride": true,

    // A catch clause is typed unknown rather than any. You must narrow before
    // using err.message — which is correct, because anything can be thrown.
    "useUnknownInCatchVariables": true,

    // Bans accidental fallthrough between switch cases. Cheap, and it pairs
    // with the exhaustive-union pattern above.
    "noFallthroughCasesInSwitch": true,

    // Reports locals and parameters that are declared and never read. Keeps
    // dead code from accumulating quietly during refactors.
    "noUnusedLocals": true,
    "noUnusedParameters": true,

    // Distinguishes an optional property from one explicitly set to undefined.
    // Strictest of the set and the one to adopt last — it surfaces genuine
    // ambiguity in existing code, which is valuable but not on a deadline.
    "exactOptionalPropertyTypes": true
  }
}

Two of these need context. noUncheckedIndexedAccess is the highest-value flag on the list and also the noisiest to adopt — it will produce hundreds of errors on a mature codebase, nearly all of them legitimate. exactOptionalPropertyTypes is the one I introduce last, or skip on projects with heavy third-party type dependencies, because library types often do not model the distinction and you end up fighting definitions you do not own.

For adoption on an existing codebase, the approach that actually finishes: enable the flag, commit the resulting errors as a baseline, and fix them directory by directory behind a tracking issue. A single PR that turns on four flags and touches two hundred files is unreviewable, and unreviewable PRs are how strictness gets reverted.

Which TypeScript patterns should you avoid?

Four that I remove during code review, and the reason each one is worse than it looks.

Type assertions standing in for validation. const user = (await res.json()) as User is a lie the compiler cannot check — the value came from the network and might be anything. It converts a clear runtime error at the boundary into a confusing one three components later. Parse at the boundary instead, with a schema validator or a hand-written type guard:

function isPatient(value: unknown): value is Patient {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === "string" && typeof v.name === "string" && typeof v.mrn === "string";
}

export async function fetchPatient(id: string): Promise<Patient> {
  const res = await fetch("/api/patients/" + id);
  if (!res.ok) throw new Error("Failed to load patient " + id);

  const body: unknown = await res.json();
  if (!isPatient(body)) throw new Error("Malformed patient payload for " + id);

  return body; // narrowed to Patient, and genuinely checked
}

Enums where a union of string literals would do. TypeScript enums generate runtime objects, do not narrow as cleanly, and interoperate awkwardly with JSON. type Ward = "icu" | "maternity" costs nothing at runtime, autocompletes identically, and compares directly against API strings.

Optional-everything props. A component typed { title?: string; onSave?: () => void; items?: Item[] } compiles when called with no props at all and then crashes. If the component cannot function without items, items is required. Optionality is a design statement, not a convenience.

Generics with a single call site. A generic parameter that is only ever instantiated one way adds a layer of indirection for no flexibility. My bar, stated earlier, is two call sites that would otherwise cast or duplicate — below that, the concrete type reads faster and refactors more easily.

What went wrong: the shared type nobody could change

The most instructive typing mistake I have made was a single ApiResponse interface shared across an entire application:

// Do not do this.
interface ApiResponse {
  data?: unknown;
  error?: string;
  meta?: { page?: number; total?: number };
}

It looked like sensible deduplication in month one. By month four every consumer was casting response.data to whatever it expected, which meant the type provided no safety at any call site while still requiring optional-chaining everywhere. Worse, it could not be tightened: any change broke forty files at once, so it stayed permanently at the loosest shape that satisfied all of them.

The replacement was a generic envelope plus per-endpoint types — roughly forty lines more code, and every response shape checked:

export type ApiResult<T> =
  | { ok: true; data: T; meta?: { page: number; total: number } }
  | { ok: false; error: string; status: number };

What I would do differently: apply the same rule to types that I apply to components — deduplicate on *behaviour*, not on *shape*. Two endpoints returning objects with a data field are not related simply because their outlines rhyme, and a shared type that must accommodate every caller ends up describing nothing.

The payoff

On the teams I lead, the test of good typing is onboarding speed: a new developer should be able to follow types from an API response to a rendered component without opening the docs. That navigability is what you are actually buying. More on the team side of this in how I structure React components for teams of 10+.

Muzamal Ali

Muzamal Ali — Senior Frontend Engineer & Team Lead

Senior Frontend Engineer with 5+ years building production React and Next.js applications. I've led teams of 3–9 developers across healthcare, aviation, AI, and SaaS platforms. Based in Pakistan, working async with European tech teams.

Working on something similar?

I help European tech teams ship better frontends.

Related Articles