Loading...
Loading...
By continuing to use the platform, you accept the terms of the Privacy Policy and the use of cookies.
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.
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.
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.
// 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
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}`;
}
}
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;
}
}
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
}
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>
);
}
}
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;
}
}
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
};
}
}
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;
}
}
}
// 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
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
// Bad - type too wide
type BadShape = {
kind: string; // Should be literal!
radius: number;
};
// Good
type GoodShape = {
kind: 'circle'; // Literal type
radius: number;
};
// 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 };
// Bad
type BadAction = {
type?: 'INCREMENT'; // Optional!
};
// Good
type GoodAction = {
type: 'INCREMENT'; // Required
};
Discriminated Unions:
switch and ifneverIn Interviews:
Important to be able to: