TypeScript discriminated unions make impossible states impossible
I had a component with three boolean props: isLoading, hasError, hasData. There were eight possible combinations but only three were valid. The other five (isLoading=true AND hasData=true, etc.) were impossible states that my code had to defensively handle anyway. Discriminated unions eliminate impossible states entirely by encoding them in the type system.
The classic API response pattern
// Bad: multiple booleans that can conflict
interface BadState {
isLoading: boolean;
hasError: boolean;
data: User | null;
error: string | null;
}
// isLoading=true AND data!=null is "impossible" but not prevented by types
// Good: discriminated union — one of these, never ambiguous
type AsyncState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// TypeScript narrows correctly in each branch
function render(state: AsyncState) {
switch (state.status) {
case 'loading':
return ;
case 'success':
return ; // state.data is User, not User | undefined
case 'error':
return ; // state.error is Error
case 'idle':
return null;
}
}
Payment flow state machine
type PaymentState =
| { stage: 'input'; cardDetails: null }
| { stage: 'validating'; cardDetails: CardDetails }
| { stage: 'processing'; cardDetails: CardDetails; transactionId: string }
| { stage: 'success'; transactionId: string; receipt: string }
| { stage: 'failed'; transactionId: string; reason: string };
function processPayment(state: PaymentState): PaymentState {
switch (state.stage) {
case 'input':
// Can't access transactionId here — it doesn't exist in this stage
return { stage: 'validating', cardDetails: collectCardDetails() };
case 'validating':
const valid = validateCard(state.cardDetails);
if (!valid) return { stage: 'input', cardDetails: null };
const txId = generateTransactionId();
return { stage: 'processing', cardDetails: state.cardDetails, transactionId: txId };
case 'processing':
const result = chargeCard(state.cardDetails, state.transactionId);
if (result.ok) {
return { stage: 'success', transactionId: state.transactionId, receipt: result.receipt };
}
return { stage: 'failed', transactionId: state.transactionId, reason: result.error };
}
// TypeScript knows these stages have no transitions
return state;
}
Result type: typed error handling
type Result =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: string): Promise> {
const user = await db.users.findById(id);
if (!user) return { ok: false, error: 'not_found' };
if (!canAccess(user)) return { ok: false, error: 'unauthorized' };
return { ok: true, value: user };
}
// Usage — exhaustive error handling
const result = await fetchUser(userId);
if (!result.ok) {
switch (result.error) {
case 'not_found':
return res.status(404).json({ error: 'User not found' });
case 'unauthorized':
return res.status(403).json({ error: 'Access denied' });
// TypeScript will error if you add a new error type and forget a case
}
}
// result.value is User here — TypeScript knows
return res.json({ user: result.value });
Form validation state
type FieldState =
| { status: 'pristine' }
| { status: 'valid'; value: string }
| { status: 'invalid'; value: string; error: string };
type FormState = {
email: FieldState;
password: FieldState;
name: FieldState;
};
function isFormValid(form: FormState): boolean {
return (
form.email.status === 'valid' &&
form.password.status === 'valid' &&
form.name.status === 'valid'
);
}
function getFormValues(form: FormState): Record | null {
if (!isFormValid(form)) return null;
// After isFormValid check, TypeScript still needs narrowing
// (it can't correlate the check with the types without type guards)
return {
email: form.email.status === 'valid' ? form.email.value : '',
password: form.password.status === 'valid' ? form.password.value : '',
name: form.name.status === 'valid' ? form.name.value : '',
};
}
The key mental shift: instead of "what booleans do I need?" ask "what are the valid states this thing can be in?" List them. Each state gets its own type with exactly the fields it needs. The impossible combinations simply cannot be constructed — they don't exist in the type system. This is the "make illegal states unrepresentable" principle from functional programming, and TypeScript discriminated unions make it practical in everyday code.