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