TypeScript Patterns for Production React Applications
TypeScript adoption in the React ecosystem is nearly universal, but most projects only scratch the surface of what the type system can do. Moving beyond interface Props and useState<Type> to advanced patterns transforms your codebase — making illegal states unrepresentable, eliminating entire categories of runtime errors, and improving the developer experience.
These are the patterns I use in every production web application I build.
1. Discriminated Unions for State Machines
Component state often exists in multiple mutually exclusive phases. Using discriminated unions encodes this directly in the type system.
```typescript type AsyncState<T, E = Error> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: E };
function useAsync<T>(fn: () => Promise<T>) { const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const execute = useCallback(async () => { setState({ status: 'loading' }); try { const data = await fn(); setState({ status: 'success', data }); } catch (error) { setState({ status: 'error', error: error as Error }); } }, [fn]);
return { ...state, execute }; } ```
Consuming this state becomes exhaustive — TypeScript will warn you if you miss a state:
```tsx function Profile({ userId }: { userId: string }) { const { status, data, error, execute } = useAsync( () => fetchUser(userId) );
switch (status) { case 'idle': case 'loading': return <Skeleton />; case 'success': return <ProfileCard user={data} />; case 'error': return <ErrorDisplay error={error} onRetry={execute} />; } } ```
The default case is intentionally omitted. If a new status is added later, TypeScript flags every switch statement, preventing silent UI regressions.
2. Generic Components with Constrained Types
Generic components let you build reusable UI primitives without sacrificing type safety.
```tsx interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string; emptyState?: React.ReactNode; }
function List<T>({ items, renderItem, keyExtractor, emptyState }: ListProps<T>) { if (items.length === 0 && emptyState) return <>{emptyState}</>; return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item)}>{renderItem(item, index)}</li> ))} </ul> ); }
// Usage with full type inference <List items={users} keyExtractor={(user) => user.id} renderItem={(user) => <UserCard user={user} />} emptyState={<EmptyUsers />} /> ```
3. Branded Types for Primitive Safety
Primitive types like string are too loose. A user ID and an API key are both strings, but passing one where the other is expected is a bug. Branded types create distinct types from primitives:
```typescript type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>; type ApiKey = Brand<string, 'ApiKey'>;
function createUserId(id: string): UserId { return id as UserId; }
function fetchUser(id: UserId): Promise<User> { / ... / }
const rawId = 'abc-123'; const userId = createUserId(rawId);
fetchUser(rawId); // Error! Argument of type 'string' not assignable to 'UserId' fetchUser(userId); // OK ```
For runtime validation, add a zod schema:
```typescript import { z } from 'zod';
const UserIdSchema = z.string().brand('UserId');
function createUserId(id: string): UserId { return UserIdSchema.parse(id) as UserId; } ```
4. Exhaustive Type Guards
When working with union types, type guards narrow the union. But they need to stay in sync with the union definition.
```typescript type Action = | { type: 'ADD_TODO'; payload: { text: string } } | { type: 'TOGGLE_TODO'; payload: { id: string } } | { type: 'DELETE_TODO'; payload: { id: string } } | { type: 'CLEAR_COMPLETED' };
// The exhaustive check ensures every action is handled function todoReducer(state: Todo[], action: Action): Todo[] { switch (action.type) { case 'ADD_TODO': return [...state, { id: crypto.randomUUID(), text: action.payload.text, completed: false }]; case 'TOGGLE_TODO': return state.map((t) => t.id === action.payload.id ? { ...t, completed: !t.completed } : t); case 'DELETE_TODO': return state.filter((t) => t.id !== action.payload.id); case 'CLEAR_COMPLETED': return state.filter((t) => !t.completed); default: // This line causes a compile error if a new action is added and not handled const _exhaustive: never = action; return _exhaustive; } } ```
The never assignment in the default case triggers a compile error when a new action type is introduced but not handled. This prevents the most common reducer bug.
5. Error Handling with Result Types
Traditional try/catch loses type information. A Result type makes error handling explicit and type-safe:
```typescript type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E };
async function fetchUserData(id: UserId): Promise<Result<User, ApiError>> { try { const response = await fetch(/api/users/${id}); if (!response.ok) { return { ok: false, error: await response.json() }; } return { ok: true, value: await response.json() }; } catch (error) { return { ok: false, error: new ApiError('Network failure') }; } }
// Usage forces handling both cases const result = await fetchUserData(userId); if (result.ok) { console.log(result.value.name); // Typed as User } else { console.error(result.error.message); // Typed as ApiError } ```
6. Template Literal Types for Event Handlers
When building form or event systems, template literal types ensure consistency:
``typescript type FormField = 'email' | 'password' | 'name'; type FormEvent = change:${FormField} | blur:${FormField} | submit`;
type FormHandler = { on(event: FormEvent, handler: (value: any) => void): void; };
// Only valid events compile: form.on('change:email', handler); // OK form.on('change:phone', handler); // Error! ```
Conclusion
TypeScript's type system is a powerful tool for building reliable React applications, but most teams leave significant value on the table. Discriminated unions, branded types, exhaustive checks, and Result types transform TypeScript from a simple type checker into a design tool that catches bugs at compile time instead of runtime. The investment in typing discipline pays for itself many times over in reduced debugging and increased confidence during refactoring.
Building a TypeScript React app and want a code review? Reach out and I'll audit your type patterns for maximum safety.
---