How to get into BigTech? Top devs share their journey —watch on YouTube
Practice TS Problems

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
Practice TS Problems