TypeScript Patterns That Hold Up in Large Frontends
Discriminated unions, branded types, schema-derived types and the small set of TypeScript patterns that keep a growing React codebase honest without turning it into type golf.
Quick answer
The TypeScript patterns worth adopting in a large frontend are: discriminated unions for UI state, types derived from runtime schemas so validation and types cannot drift, branded types for identifiers, and strict compiler flags including noUncheckedIndexedAccess. Avoid clever conditional types in application code — they cost more in comprehension than they save in bugs.
Model states, not booleans
Four booleans describe sixteen states, twelve of which are impossible. A discriminated union describes exactly the states that exist, and the compiler stops you from rendering data that has not arrived.
type Result<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| { status: "ready"; data: T };
// Accessing result.data is only legal after narrowing to "ready"Derive types from schemas
Hand-written interfaces next to hand-written validators drift within a sprint. Declare the schema once and infer the type — one source of truth for the runtime shape and the compile-time shape.
const Order = z.object({
id: z.string(),
total: z.number().nonnegative(),
status: z.enum(["pending", "paid", "refunded"]),
});
export type Order = z.infer<typeof Order>;Brand your identifiers
UserId and OrderId are both strings, which means the compiler happily lets you pass one where the other belongs. Branding costs three lines and eliminates an entire class of bug that only shows up in production data.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function cancelOrder(id: OrderId): Promise<void>;
// cancelOrder(userId) -> compile errorCompiler flags that pay for themselves
| Flag | Catches |
|---|---|
| strict | The baseline; everything else assumes it |
| noUncheckedIndexedAccess | array[i] and record lookups that can be undefined |
| exactOptionalPropertyTypes | Passing undefined where a key should be absent |
| verbatimModuleSyntax | Type imports leaking into the runtime bundle |
Where to stop
Deeply recursive conditional types belong in libraries, not in product code. If a teammate needs ten minutes to understand a type error, the type is now the cost centre.
Types are documentation the compiler enforces. Write them for the next person, not for the type-level puzzle.
Checklist
- strict mode plus noUncheckedIndexedAccess enabled
- UI state modelled as a discriminated union
- API types inferred from runtime schemas
- No `as` casts at network boundaries
- Identifiers branded where mix-ups are possible
- Shared types exported from one module, not duplicated
- Conditional-type gymnastics kept out of product code
Common mistakes
- Using `any` to unblock a build and never returning to it.
- Duplicating a Zod schema and an interface for the same payload.
- Typing props as broad Records instead of explicit shapes.
- Enabling strict on a large codebase all at once instead of per-directory.
- Treating type errors as build noise rather than defects.
Frequently asked questions
Are enums or union types better?
Prefer string literal unions. They erase at compile time, work naturally with JSON, and avoid the runtime object that TypeScript enums generate.
Should I type every component prop explicitly?
Yes for exported and shared components; inference is fine for local ones. Explicit props are the contract other people read.
How do I migrate a large JavaScript codebase?
Enable checkJs with allowJs, convert leaf modules first, and turn on strict per directory. A big-bang migration stalls and gets reverted.
Summary
Model impossible states out of existence, derive types from schemas, brand identifiers, turn on the strict flags, and stop before your types become a second language nobody on the team speaks.
working on something like this?