TypeScript Patterns I Use in Every React Project

TypeScript and React are a natural fit. But TypeScript can feel like it is fighting you if you do not lean into the right patterns. After using TypeScript in React projects for several years, here are the patterns I have found most valuable.
Discriminated Unions for State
Instead of a single state object with optional fields, I use discriminated unions to model different states explicitly:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function useProjects() {
const [state, setState] = useState<FetchState<Project[]>>({ status: "idle" });
// TypeScript narrows the type based on status
if (state.status === "success") {
// state.data is available and typed as Project[]
}
}
This eliminates an entire class of bugs where you accidentally access data before it is loaded, or display content when there is an error.
Component Props with Defaults
I always define props as an interface with sensible defaults, rather than making everything required:
interface ButtonProps {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
onClick?: () => void;
}
function Button({ variant = "primary", size = "md", children, onClick }: ButtonProps) {
return <button className={`btn-${variant} btn-${size}`} onClick={onClick}>{children}</button>;
}
The key detail: use ? for optional props in the interface, then destructure with defaults in the function signature. This gives you type safety without forcing every consumer to specify every prop.
Const Assertions for Configuration
When defining configuration objects or route maps, as const gives you literal types instead of broad strings:
const ROUTES = {
home: "/",
about: "/about",
services: "/services",
contact: "/contact",
} as const;
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// Type is "/" | "/about" | "/services" | "/contact"
This catches typos at compile time and gives your IDE perfect autocomplete for route values.
Generic API Fetchers
A typed fetch wrapper eliminates repetitive type casting across your codebase:
async function apiFetch<T>(endpoint: string): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json() as Promise<T>;
}
// Usage - fully typed
const projects = await apiFetch<Project[]>("/projects");
const post = await apiFetch<BlogPost>("/posts/my-slug");
Every API call gets type inference automatically. If the shape of your data changes, TypeScript catches every place it is used.
The Payoff
These patterns add minimal overhead but catch real bugs. The discriminated union pattern alone has saved me from countless “cannot read property of undefined” errors. TypeScript is at its best when you model your domain accurately — and these patterns help you do exactly that.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.
Discussion (0)
Sign in to join the discussion
No comments yet. Be the first to share your thoughts.
Related Articles

Building and Deploying Full-Stack Apps with AI Assistance
A weekend project walkthrough: building a full-stack task manager from architecture planning to deployment, with AI as t

AI-Assisted Database Design and Query Optimization
How to use AI for schema design, index recommendations, N+1 detection, and query optimization in PostgreSQL and MySQL.

Automating Repetitive Tasks with AI Scripts
Practical patterns for using AI to generate automation scripts for data migration, file processing, and scheduled tasks.