Type-Safe React Architecture
Type-Safe React Architecture
TypeScript isn't just about catching bugs. It's about building systems that are impossible to misuse.
Why Type Safety Matters
After 20+ years of engineering, I've learned: the best code is code that guides you toward correct usage.
The Cost of Runtime Errors
Runtime errors in production are expensive:
- User frustration - Broken features hurt trust
- Debug time - Finding issues in production is slow
- Lost revenue - Crashes during checkout cost money
- Team velocity - Fear of breaking things slows development
TypeScript moves these errors to compile time.
Architecture Patterns
1. Strict Type Configuration
Start with the strictest possible tsconfig.json:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true } }
Yes, it's painful at first. But it catches so many bugs.
2. Component Props as Contracts
Your component props are API contracts:
// Weak typing interface ButtonProps { variant?: string; size?: string; onClick?: Function; } // Strong typing type ButtonVariant = "primary" | "secondary" | "ghost"; type ButtonSize = "sm" | "md" | "lg"; interface ButtonProps { variant?: ButtonVariant; size?: ButtonSize; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; children: React.ReactNode; }
Now your IDE will autocomplete variants and catch typos.
3. Discriminated Unions for State
Handle complex state with discriminated unions:
type FetchState<T> = | { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }; function UserProfile() { const [state, setState] = useState<FetchState<User>>({ status: "idle", }); // TypeScript knows what properties exist in each branch if (state.status === "success") { return <div>{state.data.name}</div>; // ✅ data exists } if (state.status === "error") { return <div>{state.error.message}</div>; // ✅ error exists } return <Loading />; }
This pattern eliminates entire classes of bugs.
4. Generic Components Done Right
Make your components reusable without losing type safety:
interface SelectOption<T> { value: T; label: string; } interface SelectProps<T> { options: SelectOption<T>[]; value: T; onChange: (value: T) => void; } function Select<T>({ options, value, onChange }: SelectProps<T>) { return ( <select value={String(value)} onChange={(e) => { const option = options.find( (opt) => String(opt.value) === e.target.value ); if (option) onChange(option.value); }} > {options.map((opt) => ( <option key={String(opt.value)} value={String(opt.value)}> {opt.label} </option> ))} </select> ); } // Usage - fully type-safe! <Select<UserRole> options={[ { value: "admin", label: "Admin" }, { value: "user", label: "User" }, ]} value={role} onChange={setRole} // TypeScript knows this is UserRole />
5. Form Validation with Zod
Combine React Hook Form with Zod for bulletproof forms:
import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; const userSchema = z.object({ email: z.string().email("Invalid email"), age: z.number().min(18, "Must be 18+"), role: z.enum(["admin", "user"]), }); type UserFormData = z.infer<typeof userSchema>; function UserForm() { const { register, handleSubmit, formState: { errors } } = useForm<UserFormData>({ resolver: zodResolver(userSchema), }); const onSubmit = (data: UserFormData) => { // data is fully typed and validated! console.log(data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("email")} /> {errors.email && <span>{errors.email.message}</span>} </form> ); }
One schema, both runtime validation and TypeScript types.
API Integration
Type your API responses:
// Define your API types interface User { id: string; name: string; email: string; } interface ApiResponse<T> { data: T; error?: string; } // Type-safe API client async function fetchUser(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); const json: ApiResponse<User> = await response.json(); if (json.error) { throw new Error(json.error); } return json.data; } // Use with TanStack Query const { data: user } = useQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id), }); // user is typed as User | undefined
Testing Benefits
TypeScript makes testing easier:
import { render, screen } from "@testing-library/react"; // TypeScript catches missing props render(<Button>Click me</Button>); // ✅ // @ts-expect-error - variant must be valid render(<Button variant="invalid">Click me</Button>); // ❌
Developer Experience Wins
With proper TypeScript setup:
- Refactoring is safe - Rename a prop, find all usages instantly
- Autocomplete everywhere - Your IDE knows what's valid
- Self-documenting code - Types explain usage
- Faster onboarding - New devs understand APIs from types
- Fewer PR comments - Type checker catches issues
Common Mistakes
Using any
Never use any. Use unknown if you must:
// Bad function processData(data: any) { return data.value; // No safety } // Good function processData(data: unknown) { if (typeof data === "object" && data !== null && "value" in data) { return (data as { value: string }).value; } throw new Error("Invalid data"); }
Ignoring Errors
Don't @ts-ignore your way out of problems. Fix the types.
Over-complicating Types
Keep it simple. Complex types are hard to maintain.
Conclusion
Type safety isn't optional for serious React applications. It's the foundation of:
- Reliable software
- Great developer experience
- Fast feature development
- Confident refactoring
Invest in your type system. Your future self will thank you.
Want to discuss TypeScript architecture? Let's connect.