Skip to main content
Practice 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

typescript
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

typescript
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

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

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

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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

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

typescript
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

typescript
// Bad - type too wide type BadShape = { kind: string; // Should be literal! radius: number; }; // Good type GoodShape = { kind: 'circle'; // Literal type radius: number; };

Inconsistent Discriminants

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

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

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?
Practice Problems