TypeScript Patterns That Keep Large React Codebases Maintainable
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.
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:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };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.
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.
Configuration that holds the line
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.
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+.