Back to Blog
TypeScriptDiscriminated UnionsType SafetyPatterns

TypeScript Discriminated Unions

Master discriminated unions in TypeScript. From state machines to API responses to exhaustive checking.

B
Bootspring Team
Engineering
April 6, 2021
7 min read

Discriminated unions model states safely with TypeScript. Here's how to use them effectively.

Basic Discriminated Union#

1// Shape with discriminant property 'kind' 2type Circle = { 3 kind: 'circle'; 4 radius: number; 5}; 6 7type Rectangle = { 8 kind: 'rectangle'; 9 width: number; 10 height: number; 11}; 12 13type Triangle = { 14 kind: 'triangle'; 15 base: number; 16 height: number; 17}; 18 19type Shape = Circle | Rectangle | Triangle; 20 21// Type narrowing with discriminant 22function calculateArea(shape: Shape): number { 23 switch (shape.kind) { 24 case 'circle': 25 // TypeScript knows shape is Circle 26 return Math.PI * shape.radius ** 2; 27 case 'rectangle': 28 // TypeScript knows shape is Rectangle 29 return shape.width * shape.height; 30 case 'triangle': 31 // TypeScript knows shape is Triangle 32 return (shape.base * shape.height) / 2; 33 } 34} 35 36const circle: Circle = { kind: 'circle', radius: 5 }; 37console.log(calculateArea(circle)); // 78.54

API Response States#

1// Loading, success, error states 2type LoadingState = { 3 status: 'loading'; 4}; 5 6type SuccessState<T> = { 7 status: 'success'; 8 data: T; 9}; 10 11type ErrorState = { 12 status: 'error'; 13 error: Error; 14}; 15 16type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState; 17 18// Usage in component 19function UserProfile({ state }: { state: AsyncState<User> }) { 20 switch (state.status) { 21 case 'loading': 22 return <Spinner />; 23 case 'success': 24 return <div>{state.data.name}</div>; 25 case 'error': 26 return <div>Error: {state.error.message}</div>; 27 } 28} 29 30// Type guard function 31function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> { 32 return state.status === 'success'; 33} 34 35function getData<T>(state: AsyncState<T>): T | null { 36 return isSuccess(state) ? state.data : null; 37}

Redux Actions#

1// Action types with discriminant 2type AddTodoAction = { 3 type: 'ADD_TODO'; 4 payload: { id: string; text: string }; 5}; 6 7type ToggleTodoAction = { 8 type: 'TOGGLE_TODO'; 9 payload: { id: string }; 10}; 11 12type DeleteTodoAction = { 13 type: 'DELETE_TODO'; 14 payload: { id: string }; 15}; 16 17type ClearCompletedAction = { 18 type: 'CLEAR_COMPLETED'; 19}; 20 21type TodoAction = 22 | AddTodoAction 23 | ToggleTodoAction 24 | DeleteTodoAction 25 | ClearCompletedAction; 26 27// Reducer with type narrowing 28function todoReducer(state: Todo[], action: TodoAction): Todo[] { 29 switch (action.type) { 30 case 'ADD_TODO': 31 return [ 32 ...state, 33 { id: action.payload.id, text: action.payload.text, completed: false }, 34 ]; 35 case 'TOGGLE_TODO': 36 return state.map(todo => 37 todo.id === action.payload.id 38 ? { ...todo, completed: !todo.completed } 39 : todo 40 ); 41 case 'DELETE_TODO': 42 return state.filter(todo => todo.id !== action.payload.id); 43 case 'CLEAR_COMPLETED': 44 return state.filter(todo => !todo.completed); 45 } 46}

Exhaustive Checking#

1// Ensure all cases are handled 2function assertNever(value: never): never { 3 throw new Error(`Unhandled value: ${value}`); 4} 5 6type PaymentMethod = 7 | { type: 'card'; cardNumber: string } 8 | { type: 'paypal'; email: string } 9 | { type: 'crypto'; wallet: string }; 10 11function processPayment(method: PaymentMethod) { 12 switch (method.type) { 13 case 'card': 14 return chargeCard(method.cardNumber); 15 case 'paypal': 16 return chargePaypal(method.email); 17 case 'crypto': 18 return chargeCrypto(method.wallet); 19 default: 20 // TypeScript error if case is missing 21 return assertNever(method); 22 } 23} 24 25// Add new type - TypeScript will error at assertNever 26type PaymentMethodV2 = 27 | { type: 'card'; cardNumber: string } 28 | { type: 'paypal'; email: string } 29 | { type: 'crypto'; wallet: string } 30 | { type: 'bank'; accountNumber: string }; // New type

State Machines#

1// Authentication state machine 2type AuthState = 3 | { status: 'idle' } 4 | { status: 'authenticating'; email: string } 5 | { status: 'authenticated'; user: User } 6 | { status: 'error'; error: string }; 7 8type AuthEvent = 9 | { type: 'LOGIN'; email: string; password: string } 10 | { type: 'LOGIN_SUCCESS'; user: User } 11 | { type: 'LOGIN_FAILURE'; error: string } 12 | { type: 'LOGOUT' }; 13 14function authReducer(state: AuthState, event: AuthEvent): AuthState { 15 switch (state.status) { 16 case 'idle': 17 if (event.type === 'LOGIN') { 18 return { status: 'authenticating', email: event.email }; 19 } 20 return state; 21 22 case 'authenticating': 23 switch (event.type) { 24 case 'LOGIN_SUCCESS': 25 return { status: 'authenticated', user: event.user }; 26 case 'LOGIN_FAILURE': 27 return { status: 'error', error: event.error }; 28 default: 29 return state; 30 } 31 32 case 'authenticated': 33 if (event.type === 'LOGOUT') { 34 return { status: 'idle' }; 35 } 36 return state; 37 38 case 'error': 39 if (event.type === 'LOGIN') { 40 return { status: 'authenticating', email: event.email }; 41 } 42 return state; 43 } 44}

Form Validation#

1type ValidationResult = 2 | { valid: true } 3 | { valid: false; errors: string[] }; 4 5function validateEmail(email: string): ValidationResult { 6 if (!email) { 7 return { valid: false, errors: ['Email is required'] }; 8 } 9 if (!email.includes('@')) { 10 return { valid: false, errors: ['Invalid email format'] }; 11 } 12 return { valid: true }; 13} 14 15function handleValidation(result: ValidationResult) { 16 if (result.valid) { 17 console.log('Form is valid'); 18 // No errors property available here 19 } else { 20 console.log('Errors:', result.errors); 21 // errors is available and typed as string[] 22 } 23}

HTTP Response Handling#

1type HttpResponse<T> = 2 | { status: 200; data: T } 3 | { status: 201; data: T; location: string } 4 | { status: 204 } 5 | { status: 400; error: { message: string; field?: string } } 6 | { status: 401; error: { message: string } } 7 | { status: 404; error: { message: string } } 8 | { status: 500; error: { message: string } }; 9 10function handleResponse<T>(response: HttpResponse<T>) { 11 switch (response.status) { 12 case 200: 13 case 201: 14 return { success: true, data: response.data }; 15 case 204: 16 return { success: true, data: null }; 17 case 400: 18 return { 19 success: false, 20 error: response.error.message, 21 field: response.error.field, 22 }; 23 case 401: 24 redirectToLogin(); 25 return { success: false, error: 'Unauthorized' }; 26 case 404: 27 return { success: false, error: 'Not found' }; 28 case 500: 29 logError(response.error); 30 return { success: false, error: 'Server error' }; 31 } 32}

Event Handling#

1type MouseEvent = { 2 type: 'mouse'; 3 x: number; 4 y: number; 5 button: 'left' | 'right' | 'middle'; 6}; 7 8type KeyboardEvent = { 9 type: 'keyboard'; 10 key: string; 11 modifiers: { 12 ctrl: boolean; 13 shift: boolean; 14 alt: boolean; 15 }; 16}; 17 18type TouchEvent = { 19 type: 'touch'; 20 touches: Array<{ x: number; y: number }>; 21}; 22 23type InputEvent = MouseEvent | KeyboardEvent | TouchEvent; 24 25function handleInput(event: InputEvent) { 26 switch (event.type) { 27 case 'mouse': 28 console.log(`Mouse ${event.button} at (${event.x}, ${event.y})`); 29 break; 30 case 'keyboard': 31 console.log(`Key ${event.key} pressed`); 32 if (event.modifiers.ctrl) console.log('With Ctrl'); 33 break; 34 case 'touch': 35 console.log(`${event.touches.length} touch points`); 36 break; 37 } 38}

Nested Discriminated Unions#

1type TextNode = { 2 type: 'text'; 3 content: string; 4}; 5 6type ElementNode = { 7 type: 'element'; 8 tag: string; 9 children: ASTNode[]; 10}; 11 12type CommentNode = { 13 type: 'comment'; 14 content: string; 15}; 16 17type ASTNode = TextNode | ElementNode | CommentNode; 18 19function renderNode(node: ASTNode): string { 20 switch (node.type) { 21 case 'text': 22 return node.content; 23 case 'element': 24 const children = node.children.map(renderNode).join(''); 25 return `<${node.tag}>${children}</${node.tag}>`; 26 case 'comment': 27 return `<!-- ${node.content} -->`; 28 } 29} 30 31// Recursive rendering 32const ast: ElementNode = { 33 type: 'element', 34 tag: 'div', 35 children: [ 36 { type: 'text', content: 'Hello' }, 37 { 38 type: 'element', 39 tag: 'span', 40 children: [{ type: 'text', content: 'World' }], 41 }, 42 ], 43}; 44 45console.log(renderNode(ast)); // <div>Hello<span>World</span></div>

Factory Functions#

1// Type-safe factory functions 2type NotificationVariant = 3 | { type: 'success'; message: string } 4 | { type: 'error'; message: string; code: number } 5 | { type: 'warning'; message: string } 6 | { type: 'info'; message: string; link?: string }; 7 8function createNotification( 9 type: 'success' | 'warning', 10 message: string 11): Extract<NotificationVariant, { type: typeof type }>; 12function createNotification( 13 type: 'error', 14 message: string, 15 code: number 16): Extract<NotificationVariant, { type: 'error' }>; 17function createNotification( 18 type: 'info', 19 message: string, 20 link?: string 21): Extract<NotificationVariant, { type: 'info' }>; 22function createNotification( 23 type: string, 24 message: string, 25 extra?: number | string 26): NotificationVariant { 27 switch (type) { 28 case 'success': 29 return { type: 'success', message }; 30 case 'error': 31 return { type: 'error', message, code: extra as number }; 32 case 'warning': 33 return { type: 'warning', message }; 34 case 'info': 35 return { type: 'info', message, link: extra as string | undefined }; 36 default: 37 throw new Error(`Unknown type: ${type}`); 38 } 39} 40 41const success = createNotification('success', 'Done!'); 42const error = createNotification('error', 'Failed', 500);

Best Practices#

Design: ✓ Use literal types for discriminant ✓ Name discriminant consistently (type, kind, status) ✓ Keep discriminant at top of type ✓ Use assertNever for exhaustive checks Type Safety: ✓ Prefer switch over if/else ✓ Create type guards for reuse ✓ Use Extract/Exclude for filtering ✓ Document state transitions Avoid: ✗ Optional discriminant properties ✗ Non-literal discriminant types ✗ Overlapping discriminant values ✗ Missing exhaustive checks

Conclusion#

Discriminated unions model complex states type-safely. Use them for API responses, Redux actions, state machines, and any scenario with multiple variants. The discriminant property enables TypeScript to narrow types automatically in switch statements.

Share this article

Help spread the word about Bootspring