Discriminated unions, also known as tagged unions or algebraic data types, are a powerful feature in TypeScript that allow you to create a type that can hold several different types of values, each with a unique "discriminator" property to identify its kind. This feature enables type-safe handling of values that can take on multiple forms, making your code more robust and easier to maintain. In this chapter, we will explore discriminated unions from basic concepts to advanced techniques, complete with examples and detailed explanations.
Discriminated unions are a combination of union types and literal types. They leverage a common property, known as the discriminator, to distinguish between different types within the union. This allows TypeScript to infer the type of a value based on the value of the discriminator property.
To create a discriminated union, you define several types with a common literal property (the discriminator) and then combine them using a union type. Here’s a simple example:
type Square = {
kind: 'square';
size: number;
};
type Rectangle = {
kind: 'rectangle';
width: number;
height: number;
};
type Circle = {
kind: 'circle';
radius: number;
};
type Shape = Square | Rectangle | Circle;
Square
, Rectangle
, and Circle
) each with a kind
property that acts as the discriminant.Square
has a size
property.Rectangle
has width
and height
properties.Circle
has a radius
property.Shape
that can be any of the three shapes.Type guards are used to narrow down the type of a discriminated union based on the value of the discriminant property. This allows you to perform type-specific operations safely.
function area(shape: Shape): number {
switch (shape.kind) {
case 'square':
return shape.size * shape.size;
case 'rectangle':
return shape.width * shape.height;
case 'circle':
return Math.PI * shape.radius * shape.radius;
default:
throw new Error('Unknown shape');
}
}
// Example usage
const square: Shape = { kind: 'square', size: 2 };
console.log(area(square)); // Output: 4
const circle: Shape = { kind: 'circle', radius: 3 };
console.log(area(circle)); // Output: 28.274333882308138
area
function calculates the area of a Shape
.switch
statement to check the kind
property of the shape.kind
, we calculate the area:square
, the area is size * size
.rectangle
, the area is width * height
.circle
, the area is Math.PI * radius * radius
.default
case that throws an error if an unknown shape is provided.Square
and Circle
and calculate their areas.TypeScript provides exhaustiveness checking to ensure that all possible cases of a discriminated union are handled. This is particularly useful in switch statements.
function areaWithExhaustivenessCheck(shape: Shape): number {
switch (shape.kind) {
case 'square':
return shape.size * shape.size;
case 'rectangle':
return shape.width * shape.height;
case 'circle':
return Math.PI * shape.radius * shape.radius;
default:
// This will cause a compile-time error if a new shape type is added but not handled
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape');
}
}
area
function but includes an exhaustiveness check.default
case assigns the shape
to a variable of type never
. This ensures that all possible cases are handled.switch
statement, TypeScript will produce a compile-time error.Discriminated unions can be nested to create more complex types. This allows for the representation of hierarchical data structures.
type Square = {
kind: 'square';
size: number;
};
type Rectangle = {
kind: 'rectangle';
width: number;
height: number;
};
type Circle = {
kind: 'circle';
radius: number;
};
type Shape = Square | Rectangle | Circle;
type ColoredShape = {
color: string;
shape: Shape;
};
// Example usage
const coloredSquare: ColoredShape = {
color: 'red',
shape: { kind: 'square', size: 2 }
};
console.log(coloredSquare); // Output: { color: 'red', shape: { kind: 'square', size: 2 } }
ColoredShape
that includes a color
property and a shape
property.shape
property can be any of the types defined in the Shape
union.ColoredShape
with a red square.Discriminated unions can be combined with other TypeScript features like interfaces and generics to create more flexible and reusable types.
interface ShapeBase {
kind: string;
}
interface Square extends ShapeBase {
kind: 'square';
size: number;
}
interface Rectangle extends ShapeBase {
kind: 'rectangle';
width: number;
height: number;
}
interface Circle extends ShapeBase {
kind: 'circle';
radius: number;
}
type Shape = Square | Rectangle | Circle;
// Generic function to calculate the area
function calculateArea(shape: T): number {
switch (shape.kind) {
case 'square':
return shape.size * shape.size;
case 'rectangle':
return shape.width * shape.height;
case 'circle':
return Math.PI * shape.radius * shape.radius;
default:
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape');
}
}
// Example usage
const square: Square = { kind: 'square', size: 2 };
console.log(calculateArea(square)); // Output: 4
const rectangle: Rectangle = { kind: 'rectangle', width: 3, height: 4 };
console.log(calculateArea(rectangle)); // Output: 12
ShapeBase
interface with a kind
property.ShapeBase
to create Square
, Rectangle
, and Circle
interfaces.Shape
type is a union of these interfaces.calculateArea
function is generic and works with any type that extends Shape
.switch
statement to calculate the area based on the kind
property.Discriminated unions are particularly useful in state management scenarios, such as representing different states of a network request.
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: string;
};
type ErrorState = {
status: 'error';
error: string;
};
type NetworkRequestState = LoadingState | SuccessState | ErrorState;
// Example usage
const loading: NetworkRequestState = { status: 'loading' };
const success: NetworkRequestState = { status: 'success', data: 'Data loaded successfully' };
const error: NetworkRequestState = { status: 'error', error: 'Failed to load data' };
function handleRequest(state: NetworkRequestState) {
switch (state.status) {
case 'loading':
console.log('Loading...');
break;
case 'success':
console.log(`Data: ${state.data}`);
break;
case 'error':
console.log(`Error: ${state.error}`);
break;
}
}
handleRequest(loading); // Output: Loading...
handleRequest(success); // Output: Data: Data loaded successfully
handleRequest(error); // Output: Error: Failed to load data
LoadingState
, SuccessState
, and ErrorState
) for a network request, each with a status
property as the discriminant.LoadingState
has no additional properties.SuccessState
includes a data
property to hold the successful response.ErrorState
includes an error
property to hold the error message.NetworkRequestState
type is a union of these states.handleRequest
function to process them.handleRequest
function uses a switch
statement to handle each state and log appropriate messages.Discriminated unions can also be used to represent different form states, enhancing type safety and reducing runtime errors.
type TextInput = {
kind: 'text';
value: string;
};
type NumberInput = {
kind: 'number';
value: number;
};
type CheckboxInput = {
kind: 'checkbox';
checked: boolean;
};
type FormInput = TextInput | NumberInput | CheckboxInput;
// Example usage
const textInput: FormInput = { kind: 'text', value: 'Hello' };
const numberInput: FormInput = { kind: 'number', value: 42 };
const checkboxInput: FormInput = { kind: 'checkbox', checked: true };
function handleInput(input: FormInput) {
switch (input.kind) {
case 'text':
console.log(`Text input: ${input.value}`);
break;
case 'number':
console.log(`Number input: ${input.value}`);
break;
case 'checkbox':
console.log(`Checkbox input: ${input.checked ? 'Checked' : 'Unchecked'}`);
break;
}
}
handleInput(textInput); // Output: Text input: Hello
handleInput(numberInput); // Output: Number input: 42
handleInput(checkboxInput); // Output: Checkbox input: Checked
TextInput
, NumberInput
, and CheckboxInput
), each with a kind
property as the discriminant.TextInput
has a value
property of type string
.NumberInput
has a value
property of type number
.CheckboxInput
has a checked
property of type boolean
.FormInput
type is a union of these input types.handleInput
function processes each input type using a switch
statement to log appropriate messages.Ensure that the discriminant property is simple and easy to understand. Typically, a string literal is used for clarity and simplicity.
Always use exhaustiveness checking to handle all possible cases of a discriminated union. This ensures that your code remains robust and type-safe even as the union types evolve.
Leverage type guards to safely narrow down types and perform type-specific operations. This enhances type safety and reduces the likelihood of runtime errors.
Discriminated unions are a powerful feature in TypeScript that enable you to create expressive and type-safe code. By using a common discriminant property, you can define complex types and perform type-specific operations with confidence. Whether you're managing state, handling forms, or creating dynamic APIs, discriminated unions provide a robust solution for enhancing type safety and maintainability in your TypeScript applications. Happy coding! ❤️