TypeScript Advanced Patterns for React Developers
TypeScript transforms React development from "hope nothing breaks" to "the compiler guarantees correctness." After 3+ years building production React applications as a TypeScript developer, here are the advanced patterns I use daily.
Generic Components
Generic components let you create reusable UI that preserves type information. Consider a data table component:
interface Column<T> {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
}
function DataTable<T extends { id: string }>({ data, columns, onRowClick }: DataTableProps<T>) {
return (
<table>
<thead>
<tr>{columns.map(col => <th key={String(col.key)}>{col.header}</th>)}</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id} onClick={() => onRowClick?.(row)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
When you use DataTable<User>, the columns array only accepts keys that exist on the User type. The onRowClick callback receives a fully typed User object. No runtime type errors, no defensive checks.
Discriminated Unions for State Management
Discriminated unions are the most powerful TypeScript pattern for managing async state in React. Replace boolean flags with typed states:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useAsync<T>(fetcher: () => Promise<T>): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
fetcher()
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error }));
}, []);
return state;
}
TypeScript narrows the type based on the status discriminator. After checking state.status === 'success', you can access state.data without optional chaining or null checks.
Template Literal Types for Design Systems
Template literal types create type-safe design tokens that prevent invalid combinations:
type Color = 'primary' | 'secondary' | 'neutral';
type Shade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type ColorToken = `${Color}-${Shade}`;
function useColor(token: ColorToken): string {
return `var(--color-${token})`;
}
useColor('primary-500'); // ✓ Valid
useColor('tertiary-300'); // ✗ Type error
This is the foundation of my UI engineering work — type-safe design tokens that prevent the most common source of visual inconsistencies.
Conditional Types for API Responses
Conditional types create precise types for API responses that vary based on request parameters:
type ApiResponse<T, Include extends string[] = []> =
'user' extends Include[number]
? T & { user: User }
: T;
async function fetchPost<Include extends string[] = []>(id: string, include?: Include) {
const params = include ? `?include=${include.join(',')}` : '';
const response = await fetch(`/api/posts/${id}${params}`);
return response.json() as Promise<ApiResponse<Post, Include>>;
}
const post = await fetchPost('1', ['user']);
post.user.name; // Typed correctly because we requested 'user'
Key Takeaways
- Generic components preserve type information through your entire component tree
- Discriminated unions eliminate impossible states — fewer null checks, fewer bugs
- Template literal types create self-documenting design tokens
- Conditional types generate precise types from API parameters
- TypeScript investment pays for itself in reduced debugging time and better developer experience
Written by Bhavya Panchal — Frontend Developer & UI Engineer
WORK WITH ME