Back to Blog
TypeScriptDiscriminated UnionsTypesPatterns

TypeScript Discriminated Unions Guide

Master TypeScript discriminated unions for type-safe handling of multiple variants.

B
Bootspring Team
Engineering
July 31, 2018
7 min read

Discriminated unions (tagged unions) use a common property to distinguish between variants, enabling exhaustive type checking.

Basic Discriminated Union#

1// Define variants with common 'type' property 2interface Circle { 3 type: 'circle'; 4 radius: number; 5} 6 7interface Square { 8 type: 'square'; 9 size: number; 10} 11 12interface Rectangle { 13 type: 'rectangle'; 14 width: number; 15 height: number; 16} 17 18// Union of all variants 19type Shape = Circle | Square | Rectangle; 20 21// Type-safe handling 22function getArea(shape: Shape): number { 23 switch (shape.type) { 24 case 'circle': 25 // TypeScript knows shape is Circle 26 return Math.PI * shape.radius ** 2; 27 case 'square': 28 // TypeScript knows shape is Square 29 return shape.size ** 2; 30 case 'rectangle': 31 // TypeScript knows shape is Rectangle 32 return shape.width * shape.height; 33 } 34} 35 36// Usage 37const circle: Shape = { type: 'circle', radius: 5 }; 38const area = getArea(circle); // 78.54...

Exhaustiveness Checking#

1// Helper for exhaustive checks 2function assertNever(x: never): never { 3 throw new Error(`Unexpected value: ${x}`); 4} 5 6function getArea(shape: Shape): number { 7 switch (shape.type) { 8 case 'circle': 9 return Math.PI * shape.radius ** 2; 10 case 'square': 11 return shape.size ** 2; 12 case 'rectangle': 13 return shape.width * shape.height; 14 default: 15 // If we add a new shape and forget to handle it, 16 // TypeScript will error here 17 return assertNever(shape); 18 } 19} 20 21// Add new shape 22interface Triangle { 23 type: 'triangle'; 24 base: number; 25 height: number; 26} 27 28type ShapeWithTriangle = Shape | Triangle; 29 30// Now getArea will show error at assertNever 31// because Triangle isn't handled

API Response Patterns#

1// Success and error responses 2interface SuccessResponse<T> { 3 status: 'success'; 4 data: T; 5} 6 7interface ErrorResponse { 8 status: 'error'; 9 error: { 10 code: string; 11 message: string; 12 }; 13} 14 15interface LoadingResponse { 16 status: 'loading'; 17} 18 19type ApiResponse<T> = SuccessResponse<T> | ErrorResponse | LoadingResponse; 20 21// Type-safe handling 22function handleResponse<T>(response: ApiResponse<T>): T | null { 23 switch (response.status) { 24 case 'success': 25 return response.data; 26 case 'error': 27 console.error(response.error.message); 28 return null; 29 case 'loading': 30 console.log('Still loading...'); 31 return null; 32 } 33} 34 35// React component example 36function UserProfile({ response }: { response: ApiResponse<User> }) { 37 switch (response.status) { 38 case 'loading': 39 return <Spinner />; 40 case 'error': 41 return <ErrorMessage error={response.error} />; 42 case 'success': 43 return <Profile user={response.data} />; 44 } 45}

State Machine Pattern#

1// Connection states 2interface Disconnected { 3 state: 'disconnected'; 4} 5 6interface Connecting { 7 state: 'connecting'; 8 startTime: Date; 9} 10 11interface Connected { 12 state: 'connected'; 13 connection: WebSocket; 14 connectedAt: Date; 15} 16 17interface Error { 18 state: 'error'; 19 error: string; 20 lastAttempt: Date; 21} 22 23type ConnectionState = Disconnected | Connecting | Connected | Error; 24 25// State transitions 26function handleConnectionState(state: ConnectionState) { 27 switch (state.state) { 28 case 'disconnected': 29 return { state: 'connecting', startTime: new Date() }; 30 31 case 'connecting': 32 // Can only access startTime here 33 console.log('Started at:', state.startTime); 34 break; 35 36 case 'connected': 37 // Can only access connection here 38 state.connection.send('ping'); 39 break; 40 41 case 'error': 42 // Can only access error here 43 console.error('Error:', state.error); 44 break; 45 } 46} 47 48// Reducer pattern 49function connectionReducer( 50 state: ConnectionState, 51 action: ConnectionAction 52): ConnectionState { 53 switch (action.type) { 54 case 'CONNECT': 55 if (state.state === 'disconnected') { 56 return { state: 'connecting', startTime: new Date() }; 57 } 58 return state; 59 60 case 'CONNECTED': 61 if (state.state === 'connecting') { 62 return { 63 state: 'connected', 64 connection: action.connection, 65 connectedAt: new Date() 66 }; 67 } 68 return state; 69 70 case 'DISCONNECT': 71 if (state.state === 'connected') { 72 state.connection.close(); 73 return { state: 'disconnected' }; 74 } 75 return state; 76 77 default: 78 return state; 79 } 80}

Action Types (Redux-style)#

1// Action definitions 2interface AddTodo { 3 type: 'ADD_TODO'; 4 payload: { text: string }; 5} 6 7interface ToggleTodo { 8 type: 'TOGGLE_TODO'; 9 payload: { id: number }; 10} 11 12interface RemoveTodo { 13 type: 'REMOVE_TODO'; 14 payload: { id: number }; 15} 16 17interface SetFilter { 18 type: 'SET_FILTER'; 19 payload: { filter: 'all' | 'active' | 'completed' }; 20} 21 22type TodoAction = AddTodo | ToggleTodo | RemoveTodo | SetFilter; 23 24// Type-safe reducer 25function todoReducer(state: TodoState, action: TodoAction): TodoState { 26 switch (action.type) { 27 case 'ADD_TODO': 28 return { 29 ...state, 30 todos: [ 31 ...state.todos, 32 { id: Date.now(), text: action.payload.text, completed: false } 33 ] 34 }; 35 36 case 'TOGGLE_TODO': 37 return { 38 ...state, 39 todos: state.todos.map(todo => 40 todo.id === action.payload.id 41 ? { ...todo, completed: !todo.completed } 42 : todo 43 ) 44 }; 45 46 case 'REMOVE_TODO': 47 return { 48 ...state, 49 todos: state.todos.filter(todo => todo.id !== action.payload.id) 50 }; 51 52 case 'SET_FILTER': 53 return { 54 ...state, 55 filter: action.payload.filter 56 }; 57 } 58} 59 60// Action creators 61function addTodo(text: string): AddTodo { 62 return { type: 'ADD_TODO', payload: { text } }; 63} 64 65function toggleTodo(id: number): ToggleTodo { 66 return { type: 'TOGGLE_TODO', payload: { id } }; 67}

Validation Results#

1interface ValidationSuccess { 2 isValid: true; 3 value: string; 4} 5 6interface ValidationError { 7 isValid: false; 8 errors: string[]; 9} 10 11type ValidationResult = ValidationSuccess | ValidationError; 12 13function validateEmail(email: string): ValidationResult { 14 const errors: string[] = []; 15 16 if (!email) { 17 errors.push('Email is required'); 18 } else if (!email.includes('@')) { 19 errors.push('Invalid email format'); 20 } 21 22 if (errors.length > 0) { 23 return { isValid: false, errors }; 24 } 25 26 return { isValid: true, value: email }; 27} 28 29// Usage 30const result = validateEmail('test@example.com'); 31 32if (result.isValid) { 33 // TypeScript knows result.value exists 34 console.log('Valid email:', result.value); 35} else { 36 // TypeScript knows result.errors exists 37 console.log('Errors:', result.errors.join(', ')); 38}

Nullable Patterns#

1// Optional with reason 2interface Present<T> { 3 present: true; 4 value: T; 5} 6 7interface Absent { 8 present: false; 9 reason: string; 10} 11 12type Maybe<T> = Present<T> | Absent; 13 14function getUser(id: string): Maybe<User> { 15 const user = database.find(id); 16 17 if (!user) { 18 return { present: false, reason: 'User not found' }; 19 } 20 21 if (user.deleted) { 22 return { present: false, reason: 'User has been deleted' }; 23 } 24 25 return { present: true, value: user }; 26} 27 28// Usage 29const result = getUser('123'); 30 31if (result.present) { 32 console.log(result.value.name); 33} else { 34 console.log('Missing:', result.reason); 35}

Event System#

1// Different event types 2interface ClickEvent { 3 type: 'click'; 4 x: number; 5 y: number; 6 button: 'left' | 'right' | 'middle'; 7} 8 9interface KeyEvent { 10 type: 'key'; 11 key: string; 12 modifiers: { 13 ctrl: boolean; 14 shift: boolean; 15 alt: boolean; 16 }; 17} 18 19interface ScrollEvent { 20 type: 'scroll'; 21 deltaX: number; 22 deltaY: number; 23} 24 25type UIEvent = ClickEvent | KeyEvent | ScrollEvent; 26 27function handleEvent(event: UIEvent) { 28 switch (event.type) { 29 case 'click': 30 console.log(`Click at ${event.x}, ${event.y}`); 31 break; 32 case 'key': 33 if (event.modifiers.ctrl && event.key === 's') { 34 console.log('Save shortcut'); 35 } 36 break; 37 case 'scroll': 38 console.log(`Scroll by ${event.deltaY}`); 39 break; 40 } 41}

Combining with Generics#

1// Generic result type 2interface Success<T> { 3 type: 'success'; 4 value: T; 5} 6 7interface Failure<E> { 8 type: 'failure'; 9 error: E; 10} 11 12type Result<T, E = Error> = Success<T> | Failure<E>; 13 14// Helper functions 15function success<T>(value: T): Success<T> { 16 return { type: 'success', value }; 17} 18 19function failure<E>(error: E): Failure<E> { 20 return { type: 'failure', error }; 21} 22 23// Usage 24async function fetchUser(id: string): Promise<Result<User, string>> { 25 try { 26 const response = await fetch(`/api/users/${id}`); 27 if (!response.ok) { 28 return failure(`HTTP error: ${response.status}`); 29 } 30 const user = await response.json(); 31 return success(user); 32 } catch (err) { 33 return failure('Network error'); 34 } 35} 36 37const result = await fetchUser('123'); 38if (result.type === 'success') { 39 console.log(result.value.name); 40} else { 41 console.error(result.error); 42}

Best Practices#

1// Use literal types for discriminant 2interface Good { 3 kind: 'good'; // Literal string type 4} 5 6interface Bad { 7 kind: string; // Too broad - won't narrow properly 8} 9 10// Keep discriminant property consistent 11type Event = 12 | { type: 'click'; x: number } 13 | { type: 'key'; key: string } 14 // Don't mix: | { kind: 'scroll'; delta: number } 15 16// Use const assertions for action types 17const ActionTypes = { 18 ADD: 'ADD', 19 REMOVE: 'REMOVE' 20} as const; 21 22type ActionType = typeof ActionTypes[keyof typeof ActionTypes]; 23 24// Document variants 25/** Represents the result of a validation operation */ 26type ValidationResult = 27 | { valid: true; value: string } 28 | { valid: false; errors: string[] };

Conclusion#

Discriminated unions provide type-safe handling of multiple variants using a common discriminant property. They're perfect for state machines, API responses, Redux actions, and any scenario with mutually exclusive states. Use exhaustiveness checking with never to catch unhandled cases. The pattern integrates well with TypeScript's type narrowing, giving you precise types within each branch of your conditional logic.

Share this article

Help spread the word about Bootspring