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.54API 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 typeState 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.