TypeScript Patterns I Use in Every React Project
Discriminated unions for UI state, schema-inferred API types, and typed hooks — the handful of TypeScript patterns that catch real bugs before runtime.
Founder & lead developer at WebDevStudio — React, TypeScript and MERN
TypeScript pays for itself when it makes invalid states impossible to write, not when it decorates your code with annotations the compiler could have inferred. These are the patterns that have actually caught bugs on projects I have shipped.
Model UI state as a discriminated union
Separate booleans for loading, error and data allow states that make no sense — loading and error true at once, or data present while still loading. A union makes those unrepresentable, and the compiler forces you to handle every case.
// Allows isLoading && error && data — three impossible states
type Bad = { isLoading: boolean; error?: Error; data?: User[] };
// Exactly four valid shapes, and TypeScript narrows on `status`
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };function UserList({ state }: { state: RequestState<User[]> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Skeleton />;
case "error":
return <ErrorBox message={state.error.message} />;
case "success":
// state.data is User[] here — no optional chaining needed
return <ul>{state.data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
}Infer API types from a schema, do not hand-write them
A hand-written interface for an API response is a guess that goes stale the moment the backend changes. Parse the response with a schema instead and infer the type from it — then the type is guaranteed to match what you actually validated.
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
});
export type User = z.infer<typeof userSchema>; // stays in sync automatically
export async function fetchUser(id: string): Promise<User> {
const res = await fetch("/api/users/" + id);
if (!res.ok) throw new Error("Failed to load user");
return userSchema.parse(await res.json()); // throws loudly on drift
}The real benefit shows up when the backend renames a field. Without parsing you get undefined somewhere deep in a component and a blank screen. With it you get an explicit error naming the field, at the boundary.
Make impossible prop combinations un-typeable
If a component takes either an icon or an avatar but never both, encode that in the type rather than documenting it in a comment nobody reads.
type BadgeProps = { label: string } & (
| { icon: ReactNode; avatarUrl?: never }
| { avatarUrl: string; icon?: never }
);
<Badge label="Pro" icon={<Star />} /> // ok
<Badge label="Ann" avatarUrl="/a.png" /> // ok
<Badge label="X" icon={<Star />} avatarUrl="/a" /> // compile errorType your hooks at the boundary, not everywhere
Annotate what a hook returns and let inference handle the rest. Over-annotating internals adds noise without adding safety — TypeScript already knows the types of your local variables.
// The return type is the contract worth stating explicitly
export function useDisclosure(initial = false): {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
} {
const [isOpen, setIsOpen] = useState(initial);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
return { isOpen, open, close, toggle };
}Avoid any in event handlers and forms
Form and event code is where runtime errors concentrate, so it is exactly where any hurts most. React ships precise event types, and react-hook-form with a resolver gives you a form whose field names are checked against your schema.
const form = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
});
// Typo in a field name is a compile error, not a silent no-op
<input {...form.register("email")} />
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value); // e.target is typed, not any
}Export types from the feature that owns them
Keep types beside the code they describe rather than in one growing types.ts. A single shared file becomes a dependency magnet that everything imports and nothing can safely change. Feature-local types keep pages thin and make it obvious which module owns a shape.
The pattern behind all of these is the same: push type information to the edges of the system — the API boundary, the form, the component contract — and let inference do the work in between. That is where the bugs are, and it is where the annotations earn their keep.
Where these land in a real project is the MERN architecture guide — the API boundary in particular is the same seam viewed from the server side. Once the types are holding, the React performance list is the next place worth spending time.
Interested in working together on a React or MERN project?
Get in Touch