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
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
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
// 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
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.
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:
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
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
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
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
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
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
// 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
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
// Bad - type too wide
type BadShape = {
kind: string; // Should be literal!
radius: number;
};
// Good
type GoodShape = {
kind: 'circle'; // Literal type
radius: number;
};
Inconsistent Discriminants
// 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
// 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
switchandif - 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