Hack Frontend Community

Discriminated Unions in TypeScript

What are Discriminated Unions?

Discriminated Unions (also known as Tagged Unions, Algebraic Data Types, or Sum Types) are a TypeScript pattern where each type in a union has a common property with a literal type that is used for discriminating between types.


Basic Example

type Circle = {
  kind: 'circle';
  radius: number;
};

type Square = {
  kind: 'square';
  size: number;
};

type Rectangle = {
  kind: 'rectangle';
  width: number;
  height: number;
};

type Shape = Circle | Square | Rectangle;

Here kind is the discriminant, a common property with literal types.


Using with Type Narrowing

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript knows this is Circle
      return Math.PI * shape.radius ** 2;
    
    case 'square':
      // TypeScript knows this is Square
      return shape.size ** 2;
    
    case 'rectangle':
      // TypeScript knows this is Rectangle
      return shape.width * shape.height;
  }
}

const circle: Circle = { kind: 'circle', radius: 5 };
console.log(getArea(circle));  // 78.53981633974483

TypeScript automatically narrows the type in each switch branch.


Why Use Discriminated Unions?

Type Safety

// Without discriminated unions
type ShapeBad = {
  radius?: number;
  size?: number;
  width?: number;
  height?: number;
};

function getAreaBad(shape: ShapeBad): number {
  if (shape.radius) {
    return Math.PI * shape.radius ** 2;
  }
  if (shape.size) {
    return shape.size ** 2;
  }
  if (shape.width && shape.height) {
    return shape.width * shape.height;
  }
  return 0;  // What is this?
}

// With discriminated unions - everything is explicit and type-safe

Modeling States

type LoadingState = {
  status: 'loading';
};

type SuccessState = {
  status: 'success';
  data: string;
};

type ErrorState = {
  status: 'error';
  error: string;
};

type State = LoadingState | SuccessState | ErrorState;

function renderUI(state: State) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    
    case 'success':
      return `Data: ${state.data}`;
    
    case 'error':
      return `Error: ${state.error}`;
  }
}

Exhaustiveness Checking

TypeScript can check that we've handled all possible cases.

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return action.value;
    default:
      // Exhaustiveness check
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

If we add a new action type, TypeScript will error:

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; value: number }
  | { type: 'MULTIPLY'; factor: number };  // New type

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return action.value;
    default:
      // Error: Type 'MULTIPLY' is not assignable to type 'never'
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

Practical Patterns

API Response

type ApiSuccess<T> = {
  status: 'success';
  data: T;
  timestamp: number;
};

type ApiError = {
  status: 'error';
  message: string;
  code: number;
};

type ApiResponse<T> = ApiSuccess<T> | ApiError;

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    return {
      status: 'success',
      data,
      timestamp: Date.now()
    };
  } catch (error) {
    return {
      status: 'error',
      message: error.message,
      code: 500
    };
  }
}

// Usage
const response = await fetchUser(1);

if (response.status === 'success') {
  console.log(response.data);  // User
} else {
  console.error(response.message);  // string
}

Forms and Validation

type FormIdle = {
  state: 'idle';
};

type FormValidating = {
  state: 'validating';
  fieldName: string;
};

type FormValid = {
  state: 'valid';
  values: Record<string, any>;
};

type FormInvalid = {
  state: 'invalid';
  errors: Record<string, string>;
};

type FormState = FormIdle | FormValidating | FormValid | FormInvalid;

function FormComponent({ formState }: { formState: FormState }) {
  switch (formState.state) {
    case 'idle':
      return <div>Fill out the form</div>;
    
    case 'validating':
      return <div>Validating {formState.fieldName}...</div>;
    
    case 'valid':
      return <div>Form is valid! {JSON.stringify(formState.values)}</div>;
    
    case 'invalid':
      return (
        <div>
          Errors:
          {Object.entries(formState.errors).map(([field, error]) => (
            <div key={field}>{field}: {error}</div>
          ))}
        </div>
      );
  }
}

WebSocket Messages

type ConnectedMessage = {
  type: 'connected';
  sessionId: string;
};

type MessageReceived = {
  type: 'message';
  content: string;
  author: string;
  timestamp: number;
};

type UserJoined = {
  type: 'user_joined';
  username: string;
};

type UserLeft = {
  type: 'user_left';
  username: string;
};

type DisconnectedMessage = {
  type: 'disconnected';
  reason: string;
};

type WebSocketMessage = 
  | ConnectedMessage
  | MessageReceived
  | UserJoined
  | UserLeft
  | DisconnectedMessage;

function handleMessage(message: WebSocketMessage) {
  switch (message.type) {
    case 'connected':
      console.log(`Connected with session: ${message.sessionId}`);
      break;
    
    case 'message':
      console.log(`${message.author}: ${message.content}`);
      break;
    
    case 'user_joined':
      console.log(`${message.username} joined`);
      break;
    
    case 'user_left':
      console.log(`${message.username} left`);
      break;
    
    case 'disconnected':
      console.log(`Disconnected: ${message.reason}`);
      break;
  }
}

Redux-style Actions

type FetchUsersRequest = {
  type: 'FETCH_USERS_REQUEST';
};

type FetchUsersSuccess = {
  type: 'FETCH_USERS_SUCCESS';
  payload: User[];
};

type FetchUsersFailure = {
  type: 'FETCH_USERS_FAILURE';
  error: string;
};

type Action = FetchUsersRequest | FetchUsersSuccess | FetchUsersFailure;

interface State {
  users: User[];
  loading: boolean;
  error: string | null;
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_USERS_REQUEST':
      return {
        ...state,
        loading: true,
        error: null
      };
    
    case 'FETCH_USERS_SUCCESS':
      return {
        ...state,
        loading: false,
        users: action.payload
      };
    
    case 'FETCH_USERS_FAILURE':
      return {
        ...state,
        loading: false,
        error: action.error
      };
  }
}

Nested Discriminated Unions

type NetworkError = {
  kind: 'network';
  statusCode: number;
};

type ValidationError = {
  kind: 'validation';
  fieldErrors: Record<string, string>;
};

type AuthError = {
  kind: 'auth';
  reason: 'expired' | 'invalid';
};

type AppError = NetworkError | ValidationError | AuthError;

type SuccessResult<T> = {
  status: 'success';
  data: T;
};

type ErrorResult = {
  status: 'error';
  error: AppError;
};

type Result<T> = SuccessResult<T> | ErrorResult;

function handleResult<T>(result: Result<T>) {
  if (result.status === 'success') {
    console.log(result.data);
  } else {
    // Nested narrowing
    switch (result.error.kind) {
      case 'network':
        console.error(`Network error: ${result.error.statusCode}`);
        break;
      
      case 'validation':
        console.error('Validation errors:', result.error.fieldErrors);
        break;
      
      case 'auth':
        console.error(`Auth error: ${result.error.reason}`);
        break;
    }
  }
}

Creating Helper Functions

// Type-safe action creators
type Action = 
  | { type: 'ADD_TODO'; text: string }
  | { type: 'TOGGLE_TODO'; id: number }
  | { type: 'DELETE_TODO'; id: number };

// Action creator functions
const addTodo = (text: string): Action => ({
  type: 'ADD_TODO',
  text
});

const toggleTodo = (id: number): Action => ({
  type: 'TOGGLE_TODO',
  id
});

const deleteTodo = (id: number): Action => ({
  type: 'DELETE_TODO',
  id
});

// Usage
const action = addTodo('Buy milk');  // type: Action

Extracting Types from Discriminated Unions

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_VALUE'; value: number };

// Extract specific type by discriminant
type ExtractAction<T extends Action['type']> = Extract<Action, { type: T }>;

type IncrementAction = ExtractAction<'INCREMENT'>;
// { type: 'INCREMENT' }

type SetValueAction = ExtractAction<'SET_VALUE'>;
// { type: 'SET_VALUE'; value: number }

// Extract payload
type ActionPayload<T extends Action['type']> = ExtractAction<T> extends { value: infer P }
  ? P
  : never;

type SetValuePayload = ActionPayload<'SET_VALUE'>;  // number

Common Mistakes

Forgetting Literal Types

// Bad - type too wide
type BadShape = {
  kind: string;  // Should be literal!
  radius: number;
};

// Good
type GoodShape = {
  kind: 'circle';  // Literal type
  radius: number;
};

Inconsistent Discriminants

// Bad - different names for discriminant
type Circle = { kind: 'circle'; radius: number };
type Square = { type: 'square'; size: number };  // type instead of kind!

// Good - consistent name
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; size: number };

Optional Discriminant

// Bad
type BadAction = {
  type?: 'INCREMENT';  // Optional!
};

// Good
type GoodAction = {
  type: 'INCREMENT';  // Required
};

Benefits

  • Compile-time type safety
  • Automatic type narrowing
  • Exhaustiveness checking
  • Readable and understandable code
  • No need for type assertions
  • Easy to extend with new cases
  • Excellent refactoring support

Conclusion

Discriminated Unions:

  • Pattern for modeling mutually exclusive states
  • Require common property with literal types (discriminant)
  • Automatic type narrowing in switch and if
  • Exhaustiveness checking via never
  • Ideal for states, API responses, actions
  • Better than optional properties for modeling variants

In Interviews:

Important to be able to:

  • Explain what discriminated unions and discriminant are
  • Show example with type narrowing
  • Implement exhaustiveness checking
  • Provide practical examples (API, Redux actions, states)
  • Explain advantages over optional properties
  • Show how to extract types from union