TypeScript discriminated unions make impossible states impossible
← Back
April 4, 2026NodeJS6 min read

TypeScript discriminated unions make impossible states impossible

Published April 4, 20266 min read

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

typescript
// 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

typescript
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

typescript
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

typescript
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.

Share this
← All Posts6 min read