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
| Pattern | Problem | Replace with |
|---|---|---|
as any, as unknown as T | Turns off the compiler exactly where you need it | Zod parse, type guard, or fix the type |
! non-null assertions | Runtime crash waiting to happen | Early return, ?., or narrow the union |
React.FC<Props> | Implicit children, awkward generics | Plain function with typed props |
enum | Runtime object, odd numeric behaviour | Union of string literals or as const object |
| Hand-written API types | Drift from the real API | Infer 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.