If there’s one TypeScript pattern that will transform how you think about state management in React, it’s discriminated unions. They’re the secret weapon for making invalid states impossible to represent, and they’ll save you from countless bugs. Let’s dive deep into this game-changing pattern.
What Are Discriminated Unions?
A discriminated union (also called a tagged union) is a pattern where you use a common property to distinguish between different shapes of data. Think of it as TypeScript’s way of saying “this thing can be one of several specific shapes, and I’ll help you figure out which one it is.”
// This is a discriminated union
type Status =
| { type: 'idle' }
| { type: 'loading' }
| { type: 'success'; data: string }
| { type: 'error'; message: string };
// The 'type' field is the discriminatorIn TypeScript, a union type is just a way of saying a value can be one of several different types—for example, string | number means the value could be either a string or a number, but TypeScript doesn’t inherently know which one at any given time. A discriminated union (sometimes called a tagged union) adds a special common property, usually a literal type like kind: "circle" | "square", that acts as a label to tell the compiler (and you) which branch of the union you’re working with. This “discriminator” lets TypeScript narrow the type automatically in a type-safe way when you check the value of that property, making discriminated unions far more powerful for modeling structured data and ensuring exhaustive checks.
Why They’re Powerful
The magic happens when TypeScript uses the discriminator to narrow the type:
function handleStatus(status: Status) {
switch (status.type) {
case 'idle':
// TypeScript knows status is { type: 'idle' }
return 'Ready to start';
case 'loading':
// TypeScript knows status is { type: 'loading' }
return 'Loading...';
case 'success':
// TypeScript knows status is { type: 'success'; data: string }
return `Success: ${status.data}`;
case 'error':
// TypeScript knows status is { type: 'error'; message: string }
return `Error: ${status.message}`;
}
}Real-World React Example
Let’s build a data fetching component that uses discriminated unions:
// Define our state shape
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Custom hook using the discriminated union
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
useEffect(() => {
if (!url) return;
const fetchData = async () => {
setState({ status: 'loading' });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setState({ status: 'success', data });
} catch (error) {
setState({
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error')
});
}
};
fetchData();
}, [url]);
return state;
}
// Component using the hook
const UserProfile = ({ userId }: { userId: string }) => {
const userState = useFetch<User>(`/api/users/${userId}`);
switch (userState.status) {
case 'idle':
return <div>Ready to load user</div>;
case 'loading':
return <div>Loading user...</div>;
case 'success':
// TypeScript knows userState.data exists and is a User
return (
<div>
<h1>{userState.data.name}</h1>
<p>{userState.data.email}</p>
</div>
);
case 'error':
// TypeScript knows userState.error exists
return <div>Error: {userState.error.message}</div>;
default:
// This ensures we handle all cases
const exhaustive: never = userState;
throw new Error(`Unhandled state: ${exhaustive}`);
}
};Making Invalid States Impossible
Here’s the real power - discriminated unions prevent invalid states:
// ❌ Bad: Multiple booleans lead to invalid states
interface BadState {
isLoading: boolean;
isError: boolean;
data?: User;
error?: Error;
}
// What if isLoading AND isError are both true?
// What if we have data AND error?
// ✅ Good: Discriminated union makes invalid states impossible
type GoodState =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User };
// Can't be loading AND error
// Can't have data without success
// Can't have error without error statusForm Handling with Discriminated Unions
Forms are perfect for discriminated unions:
type FormField<T> =
| { status: 'empty' }
| { status: 'validating' }
| { status: 'valid'; value: T }
| { status: 'invalid'; error: string; value?: T };
interface LoginForm {
email: FormField<string>;
password: FormField<string>;
}
const LoginComponent = () => {
const [form, setForm] = useState<LoginForm>({
email: { status: 'empty' },
password: { status: 'empty' }
});
const validateEmail = async (email: string) => {
setForm(prev => ({
...prev,
email: { status: 'validating' }
}));
// Simulate async validation
await new Promise(resolve => setTimeout(resolve, 500));
if (!email.includes('@')) {
setForm(prev => ({
...prev,
email: { status: 'invalid', error: 'Invalid email format', value: email }
}));
} else {
setForm(prev => ({
...prev,
email: { status: 'valid', value: email }
}));
}
};
const canSubmit =
form.email.status === 'valid' &&
form.password.status === 'valid';
return (
<form>
<div>
<input
type="email"
onChange={e => validateEmail(e.target.value)}
className={form.email.status === 'invalid' ? 'error' : ''}
/>
{form.email.status === 'invalid' && (
<span className="error-message">{form.email.error}</span>
)}
{form.email.status === 'validating' && (
<span className="validating">Checking...</span>
)}
</div>
<button disabled={!canSubmit}>Login</button>
</form>
);
};Validation Rules with Discriminated Unions
You can also model validation rules as a discriminated union for clear, exhaustive handling.
type ValidationRule<T> =
| { type: 'required'; message?: string }
| { type: 'minLength'; value: number; message?: string }
| { type: 'maxLength'; value: number; message?: string }
| { type: 'pattern'; value: RegExp; message?: string }
| { type: 'custom'; validate: (value: T) => boolean | string };
function validateField<T>(value: T, rules: ValidationRule<T>[]): string | null {
for (const rule of rules) {
switch (rule.type) {
case 'required':
if (!value) {
return rule.message || 'This field is required';
}
break;
case 'minLength':
if (typeof value === 'string' && value.length < rule.value) {
return rule.message || `Minimum length is ${rule.value}`;
}
break;
case 'maxLength':
if (typeof value === 'string' && value.length > rule.value) {
return rule.message || `Maximum length is ${rule.value}`;
}
break;
case 'pattern':
if (typeof value === 'string' && !rule.value.test(value)) {
return rule.message || 'Invalid format';
}
break;
case 'custom':
const result = rule.validate(value);
if (result !== true) {
return typeof result === 'string' ? result : 'Validation failed';
}
break;
}
}
return null;
}Form Submission Workflow
Whole-form workflows often benefit from tagged states like validating, validation-error, submitting, success, and error.
type FormState<T> =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'validation-error'; errors: Record<keyof T, string[]> }
| { status: 'submitting'; data: T }
| { status: 'success'; result: string }
| { status: 'error'; error: string };
interface LoginForm {
email: string;
password: string;
}
function LoginForm() {
const [state, setState] = useState<FormState<LoginForm>>({ status: 'idle' });
const [formData, setFormData] = useState<LoginForm>({ email: '', password: '' });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setState({ status: 'validating' });
const errors = validateForm(formData);
if (Object.keys(errors).length > 0) {
setState({ status: 'validation-error', errors });
return;
}
setState({ status: 'submitting', data: formData });
try {
const result = await submitLogin(formData);
setState({ status: 'success', result });
} catch (error) {
setState({ status: 'error', error: error instanceof Error ? error.message : String(error) });
}
};
switch (state.status) {
case 'idle':
case 'validating':
return (
<form onSubmit={handleSubmit} aria-busy={state.status === 'validating'}>
{/* inputs omitted for brevity */}
<button type="submit" disabled={state.status === 'validating'}>
{state.status === 'validating' ? 'Validating…' : 'Login'}
</button>
</form>
);
case 'validation-error':
return <ErrorsList errors={state.errors} />;
case 'submitting':
return <Spinner label={`Submitting for ${state.data.email}…`} />;
case 'success':
return <Success message={state.result} />;
case 'error':
return <ErrorBanner message={state.error} />;
}
}Actions and Reducers
Discriminated unions shine in Redux-style reducers:
// Action types using discriminated unions
type CartAction =
| { type: 'ADD_ITEM'; item: Product; quantity: number }
| { type: 'REMOVE_ITEM'; productId: string }
| { type: 'UPDATE_QUANTITY'; productId: string; quantity: number }
| { type: 'CLEAR_CART' }
| { type: 'APPLY_DISCOUNT'; code: string; percentage: number }
| { type: 'CHECKOUT_START' }
| { type: 'CHECKOUT_SUCCESS'; orderId: string }
| { type: 'CHECKOUT_FAILURE'; error: string };
interface CartState {
items: CartItem[];
discount: number;
checkoutStatus: 'idle' | 'processing' | 'success' | 'error';
orderId?: string;
error?: string;
}
const cartReducer = (state: CartState, action: CartAction): CartState => {
switch (action.type) {
case 'ADD_ITEM':
// TypeScript knows action has item and quantity
return {
...state,
items: [
...state.items,
{
product: action.item,
quantity: action.quantity,
},
],
};
case 'REMOVE_ITEM':
// TypeScript knows action has productId
return {
...state,
items: state.items.filter((item) => item.product.id !== action.productId),
};
case 'UPDATE_QUANTITY':
// TypeScript knows action has productId and quantity
return {
...state,
items: state.items.map((item) =>
item.product.id === action.productId ? { ...item, quantity: action.quantity } : item,
),
};
case 'APPLY_DISCOUNT':
// TypeScript knows action has code and percentage
return {
...state,
discount: action.percentage,
};
case 'CHECKOUT_SUCCESS':
// TypeScript knows action has orderId
return {
...state,
checkoutStatus: 'success',
orderId: action.orderId,
items: [],
};
case 'CHECKOUT_FAILURE':
// TypeScript knows action has error
return {
...state,
checkoutStatus: 'error',
error: action.error,
};
default:
// Exhaustiveness check
const exhaustive: never = action;
return state;
}
};Multiple Discriminators
Sometimes you need multiple fields to discriminate:
type NotificationStyle = 'banner' | 'toast' | 'modal';
type NotificationLevel = 'info' | 'warning' | 'error' | 'success';
type Notification =
| { style: 'banner'; level: 'info'; message: string }
| { style: 'banner'; level: 'warning'; message: string; dismissible: boolean }
| { style: 'toast'; level: NotificationLevel; message: string; duration: number }
| { style: 'modal'; level: 'error'; message: string; details: string; onConfirm: () => void };
function renderNotification(notification: Notification) {
if (notification.style === 'banner') {
if (notification.level === 'warning') {
// TypeScript knows dismissible exists
return (
<Banner warning dismissible={notification.dismissible}>
{notification.message}
</Banner>
);
}
// info banner
return <Banner>{notification.message}</Banner>;
}
if (notification.style === 'toast') {
// TypeScript knows duration exists
return (
<Toast level={notification.level} duration={notification.duration}>
{notification.message}
</Toast>
);
}
// Must be modal with error
// TypeScript knows details and onConfirm exist
return (
<Modal onConfirm={notification.onConfirm}>
<h2>Error</h2>
<p>{notification.message}</p>
<details>{notification.details}</details>
</Modal>
);
}Nested Discriminated Unions
You can nest discriminated unions for complex state:
type ConnectionState =
| { status: 'disconnected' }
| { status: 'connecting'; attempt: number }
| { status: 'connected'; socket: WebSocket }
| { status: 'error'; error: Error; canRetry: boolean };
type DataState<T> =
| { status: 'idle' }
| { status: 'fetching' }
| { status: 'success'; data: T; updatedAt: Date }
| { status: 'error'; error: Error };
interface AppState<T> {
connection: ConnectionState;
data: DataState<T>;
}
function getStatusMessage<T>(state: AppState<T>): string {
// Handle connection state
if (state.connection.status === 'disconnected') {
return 'Disconnected from server';
}
if (state.connection.status === 'connecting') {
return `Connecting... (attempt ${state.connection.attempt})`;
}
if (state.connection.status === 'error') {
return state.connection.canRetry
? 'Connection failed. Retrying...'
: 'Connection failed. Please refresh.';
}
// Connected - check data state
switch (state.data.status) {
case 'idle':
return 'Connected. Ready to load data.';
case 'fetching':
return 'Loading data...';
case 'success':
return `Data updated at ${state.data.updatedAt.toLocaleTimeString()}`;
case 'error':
return `Error loading data: ${state.data.error.message}`;
}
}Router State Management
Discriminated unions are perfect for routing:
type Route =
| { path: 'home' }
| { path: 'profile'; userId: string }
| { path: 'post'; postId: string; section?: 'comments' | 'edit' }
| { path: 'search'; query: string; filters: SearchFilters }
| { path: 'not-found' };
interface RouterState {
current: Route;
previous?: Route;
isTransitioning: boolean;
}
const Router = ({ state }: { state: RouterState }) => {
const { current } = state;
switch (current.path) {
case 'home':
return <HomePage />;
case 'profile':
// TypeScript knows userId exists
return <ProfilePage userId={current.userId} />;
case 'post':
// TypeScript knows postId and optional section exist
return (
<PostPage
postId={current.postId}
section={current.section}
/>
);
case 'search':
// TypeScript knows query and filters exist
return (
<SearchPage
query={current.query}
filters={current.filters}
/>
);
case 'not-found':
return <NotFoundPage />;
default:
const exhaustive: never = current;
throw new Error(`Unhandled route: ${exhaustive}`);
}
};Async Operations Pattern
Here’s a reusable pattern for async operations:
type AsyncOperation<TParams, TResult> =
| { status: 'idle' }
| { status: 'pending'; params: TParams }
| { status: 'success'; params: TParams; result: TResult; timestamp: Date }
| { status: 'failure'; params: TParams; error: Error; canRetry: boolean };
// Generic hook for async operations
function useAsyncOperation<TParams, TResult>(
operation: (params: TParams) => Promise<TResult>
) {
const [state, setState] = useState<AsyncOperation<TParams, TResult>>({
status: 'idle'
});
const execute = useCallback(async (params: TParams) => {
setState({ status: 'pending', params });
try {
const result = await operation(params);
setState({
status: 'success',
params,
result,
timestamp: new Date()
});
return result;
} catch (error) {
setState({
status: 'failure',
params,
error: error instanceof Error ? error : new Error('Unknown error'),
canRetry: true
});
throw error;
}
}, [operation]);
const retry = useCallback(() => {
if (state.status === 'failure' && state.canRetry) {
execute(state.params);
}
}, [state, execute]);
return { state, execute, retry };
}
// Using the hook
const SaveButton = ({ data }: { data: FormData }) => {
const { state, execute, retry } = useAsyncOperation(saveData);
const handleSave = () => execute(data);
switch (state.status) {
case 'idle':
return <button onClick={handleSave}>Save</button>;
case 'pending':
return <button disabled>Saving...</button>;
case 'success':
return (
<div>
✓ Saved at {state.timestamp.toLocaleTimeString()}
<button onClick={handleSave}>Save Again</button>
</div>
);
case 'failure':
return (
<div>
<span className="error">Failed: {state.error.message}</span>
{state.canRetry && (
<button onClick={retry}>Retry</button>
)}
</div>
);
}
};Wizard/Multi-Step Forms
Discriminated unions excel at multi-step processes:
type WizardStep =
| { step: 'personal'; data?: PersonalData }
| { step: 'contact'; data: PersonalData }
| { step: 'preferences'; data: PersonalData & ContactData }
| { step: 'review'; data: PersonalData & ContactData & PreferencesData }
| { step: 'complete'; data: CompleteProfile };
interface WizardState {
current: WizardStep;
history: WizardStep[];
validationErrors?: Record<string, string>;
}
const ProfileWizard = () => {
const [state, setState] = useState<WizardState>({
current: { step: 'personal' },
history: []
});
const goToNext = () => {
const { current } = state;
switch (current.step) {
case 'personal':
// Validate and collect personal data
const personalData = validatePersonalData();
if (personalData) {
setState({
current: { step: 'contact', data: personalData },
history: [...state.history, current]
});
}
break;
case 'contact':
// Current.data is PersonalData
const contactData = validateContactData();
if (contactData) {
setState({
current: {
step: 'preferences',
data: { ...current.data, ...contactData }
},
history: [...state.history, current]
});
}
break;
case 'preferences':
// Current.data is PersonalData & ContactData
const prefsData = validatePreferencesData();
if (prefsData) {
setState({
current: {
step: 'review',
data: { ...current.data, ...prefsData }
},
history: [...state.history, current]
});
}
break;
case 'review':
// Current.data is complete
submitProfile(current.data).then(result => {
setState({
current: { step: 'complete', data: result },
history: [...state.history, current]
});
});
break;
}
};
// Render appropriate step
switch (state.current.step) {
case 'personal':
return <PersonalInfoForm onNext={goToNext} />;
case 'contact':
return <ContactInfoForm data={state.current.data} onNext={goToNext} />;
case 'preferences':
return <PreferencesForm data={state.current.data} onNext={goToNext} />;
case 'review':
return <ReviewStep data={state.current.data} onSubmit={goToNext} />;
case 'complete':
return <SuccessMessage profile={state.current.data} />;
}
};Pattern Matching with Libraries
While TypeScript doesn’t have built-in pattern matching, libraries like ts-pattern make it even more powerful:
import { match } from 'ts-pattern';
type Result<T, E> = { type: 'ok'; value: T } | { type: 'error'; error: E };
const processResult = <T, E>(result: Result<T, E>) =>
match(result)
.with({ type: 'ok' }, ({ value }) => `Success: ${value}`)
.with({ type: 'error' }, ({ error }) => `Error: ${error}`)
.exhaustive();
// More complex matching
type Event =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string; shift: boolean }
| { type: 'scroll'; delta: number };
const handleEvent = (event: Event) =>
match(event)
.with({ type: 'click', x: P.number, y: P.number }, ({ x, y }) => `Clicked at (${x}, ${y})`)
.with({ type: 'keypress', shift: true }, ({ key }) => `Shift+${key} pressed`)
.with({ type: 'keypress' }, ({ key }) => `${key} pressed`)
.with({ type: 'scroll' }, ({ delta }) => `Scrolled ${delta}px`)
.exhaustive();Testing Discriminated Unions
Discriminated unions make testing easier:
// Easy to test each state
describe('UserProfile', () => {
it('shows loading state', () => {
const state: FetchState<User> = { status: 'loading' };
const { getByText } = render(<UserDisplay state={state} />);
expect(getByText('Loading...')).toBeInTheDocument();
});
it('shows user data on success', () => {
const state: FetchState<User> = {
status: 'success',
data: { id: '1', name: 'Alice', email: 'alice@example.com' }
};
const { getByText } = render(<UserDisplay state={state} />);
expect(getByText('Alice')).toBeInTheDocument();
});
it('shows error message on failure', () => {
const state: FetchState<User> = {
status: 'error',
error: new Error('Network error')
};
const { getByText } = render(<UserDisplay state={state} />);
expect(getByText('Network error')).toBeInTheDocument();
});
});Common Patterns and Tips
Always Include a Discriminator
// ✅ Good - has discriminator
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; size: number };
// ❌ Bad - no discriminator
type Shape = { radius: number } | { size: number };
// TypeScript can't easily tell these apartUse Literal Types for Discriminators
// ✅ Good - literal types
type Status = { type: 'success' } | { type: 'error' };
// ❌ Bad - using boolean
type Status = { success: true } | { success: false };
// Harder to extend and less clearKeep Related Data Together
// ✅ Good - error with its message
type Result = { status: 'success'; data: string } | { status: 'error'; message: string };
// ❌ Bad - separate optional fields
interface Result {
status: 'success' | 'error';
data?: string;
errorMessage?: string;
}Use Exhaustive Checks
function handle(value: 'a' | 'b' | 'c') {
switch (value) {
case 'a':
return 1;
case 'b':
return 2;
case 'c':
return 3;
default:
// This ensures we handle all cases
const exhaustive: never = value;
throw new Error(`Unhandled value: ${exhaustive}`);
}
}Advanced: Building a State Machine
Discriminated unions are perfect for state machines:
type TrafficLightState =
| { color: 'red'; canWalk: true; next: 'green' }
| { color: 'yellow'; canWalk: false; next: 'red' }
| { color: 'green'; canWalk: false; next: 'yellow' };
class TrafficLight {
private state: TrafficLightState = {
color: 'red',
canWalk: true,
next: 'green'
};
transition() {
switch (this.state.color) {
case 'red':
this.state = { color: 'green', canWalk: false, next: 'yellow' };
break;
case 'green':
this.state = { color: 'yellow', canWalk: false, next: 'red' };
break;
case 'yellow':
this.state = { color: 'red', canWalk: true, next: 'green' };
break;
}
}
getState() {
return this.state;
}
}
// React component using the state machine
const TrafficLightComponent = () => {
const [light] = useState(() => new TrafficLight());
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
const interval = setInterval(() => {
light.transition();
forceUpdate();
}, 2000);
return () => clearInterval(interval);
}, [light]);
const state = light.getState();
return (
<div>
<div className={`light ${state.color}`} />
{state.canWalk && <div>🚶 Walk</div>}
<div>Next: {state.next}</div>
</div>
);
};Exclusive Props Without Discriminants
Sometimes you want mutual exclusivity without an explicit variant prop. Here’s a pattern using union types and the never type:
type ExclusiveProps<T, U> =
| (T & { [K in keyof U]?: never })
| (U & { [K in keyof T]?: never });
type IconButtonProps = ExclusiveProps<
{ icon: string; 'aria-label': string },
{ children: React.ReactNode }
>;
function IconButton(props: IconButtonProps) {
if ('icon' in props) {
return (
<button aria-label={props['aria-label']}>
<Icon name={props.icon} />
</button>
);
}
return <button>{props.children}</button>;
}This creates an either-or relationship without needing a discriminant:
// ✅ Valid uses
<IconButton icon="search" aria-label="Search" />
<IconButton>Click me</IconButton>
// ❌ Invalid combinations caught by TypeScript
<IconButton icon="search">Text and icon</IconButton>
<IconButton aria-label="Label only" /> // Missing iconThe ExclusiveProps utility makes properties from one type never when the other type is active, preventing mixed usage.
Advanced React Patterns
Form Field Unions
Let’s build a flexible form field component that handles different input types:
type BaseFieldProps = {
label: string;
name: string;
required?: boolean;
error?: string;
};
type TextFieldProps = BaseFieldProps & {
type: 'text' | 'email' | 'password';
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
type SelectFieldProps = BaseFieldProps & {
type: 'select';
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
};
type CheckboxFieldProps = BaseFieldProps & {
type: 'checkbox';
checked: boolean;
onChange: (checked: boolean) => void;
};
type FieldProps = TextFieldProps | SelectFieldProps | CheckboxFieldProps;
function Field(props: FieldProps) {
const { label, name, required, error } = props;
const renderInput = () => {
switch (props.type) {
case 'text':
case 'email':
case 'password':
return (
<input
type={props.type}
name={name}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
placeholder={props.placeholder}
required={required}
/>
);
case 'select':
return (
<select
name={name}
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
required={required}
>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
case 'checkbox':
return (
<input
type="checkbox"
name={name}
checked={props.checked}
onChange={(e) => props.onChange(e.target.checked)}
required={required}
/>
);
}
};
return (
<div className="field">
<label htmlFor={name}>{label}</label>
{renderInput()}
{error && <span className="error">{error}</span>}
</div>
);
}TypeScript ensures you can’t mix incompatible props—no placeholder on selects, no options on text fields.
Button Component with Variants
Here’s how to create a button that can be either a button or a link:
type ButtonAsButton = {
variant: 'button';
onClick: () => void;
disabled?: boolean;
};
type ButtonAsLink = {
variant: 'link';
href: string;
disabled?: boolean;
};
type ButtonProps = {
children: React.ReactNode;
} & (ButtonAsButton | ButtonAsLink);
function Button(props: ButtonProps) {
const { children } = props;
if (props.variant === 'button') {
// TypeScript knows this is ButtonAsButton
return (
<button onClick={props.onClick} disabled={props.disabled}>
{children}
</button>
);
}
// TypeScript knows this is ButtonAsLink
return (
<a href={props.href} className={props.disabled ? 'disabled' : ''}>
{children}
</a>
);
}Runtime Validation with Zod
For extra safety, especially when dealing with external data, combine discriminated unions with runtime validation:
import { z } from 'zod';
const ButtonSchemaAsButton = z.object({
variant: z.literal('button'),
onClick: z.function(),
disabled: z.boolean().optional(),
});
const ButtonSchemaAsLink = z.object({
variant: z.literal('link'),
href: z.string().url(),
disabled: z.boolean().optional(),
});
const ButtonPropsSchema = z
.object({
children: z.any(), // React.ReactNode is hard to validate
})
.and(z.union([ButtonSchemaAsButton, ButtonSchemaAsLink]));
type ButtonProps = z.infer<typeof ButtonPropsSchema>;
function Button(props: ButtonProps) {
// Runtime validation catches props that TypeScript might miss
const validatedProps = ButtonPropsSchema.parse(props);
// Your component logic here...
}This is particularly valuable for components that receive props from APIs or configuration files where TypeScript can’t help.
Runtime Validation for Async UI State
You can also validate tagged UI state at runtime with a reusable schema factory:
import { z } from 'zod';
const AsyncStateSchema = <T extends z.ZodTypeAny, E extends z.ZodTypeAny = z.ZodString>(
dataSchema: T,
errorSchema?: E,
) =>
z.discriminatedUnion('status', [
z.object({ status: z.literal('idle') }),
z.object({ status: z.literal('loading') }),
z.object({ status: z.literal('success'), data: dataSchema }),
z.object({ status: z.literal('error'), error: (errorSchema ?? z.string()) as E }),
]);
const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email() });
const UserAsyncSchema = AsyncStateSchema(UserSchema);
type UserAsyncState = z.infer<typeof UserAsyncSchema>;
function parseUserAsync(input: unknown): UserAsyncState | null {
const result = UserAsyncSchema.safeParse(input);
return result.success ? result.data : null;
}Performance Considerations
Discriminated unions and exclusive props are compile-time constructs—they add zero runtime overhead. The discriminant checks become simple property access, and TypeScript’s union narrowing is eliminated during compilation.
However, be mindful of:
- Complex union types can slow TypeScript compilation on very large codebases
- Deep nesting of discriminated unions can make error messages harder to read
- Runtime validation has a cost—use it judiciously in performance-critical paths
Common Pitfalls
Forgetting the Discriminant
// ❌ This doesn't work—no discriminant
type BadUnion = { a: string } | { b: number };
function process(props: BadUnion) {
if ('a' in props) {
// This is a runtime check, not great
return props.a;
}
return props.b.toString();
}Making Discriminants Optional
// ❌ Optional discriminants defeat the purpose
type WeakUnion = { type?: 'A'; propA: string } | { type?: 'B'; propB: number };Overcomplicating Simple Cases
// ❌ Overkill for a simple boolean flag
type OverEngineered = { visible: true; content: string } | { visible: false };
// ✅ Sometimes simple is better
type Simple = {
visible: boolean;
content?: string;
};When to Use Each Pattern
Use discriminated unions when:
- You have clear variants of component behavior
- Props have completely different meanings based on context
- You want explicit, self-documenting APIs
- You need exhaustive pattern matching in switch statements
Use exclusive props when:
- You have a smaller number of mutually exclusive options
- The relationship is more about “either this or that” than distinct variants
- You want a cleaner API without extra discriminant props
Combine with runtime validation when:
- Props come from external sources (APIs, config files)
- You’re building a component library for external consumption
- Data integrity is critical for security or business logic
Conclusion
Discriminated unions are one of TypeScript’s killer features for React development. They:
- Make invalid states impossible - Can’t represent incorrect combinations
- Enable exhaustive checking - TypeScript ensures you handle all cases
- Provide excellent IntelliSense - IDE knows exactly what properties are available
- Document your intent - The types themselves explain the possible states
- Simplify testing - Each state is easy to test in isolation
Master this pattern, and you’ll write React applications that are not just type-safe, but architecturally sound. Your future self (and your teammates) will thank you!