Skip to main content

TypeScript Discriminated Unions Explained

Discriminated unions in TypeScript are an effective way to create type-safe and maintainable code. They allow you to easily model complex data structures by defining a type that can take on several forms, each marked by a shared property, often referred to as a "discriminant." This is useful when you need to handle different object types safely. By utilizing discriminated unions, you can ensure your code remains clear and manageable, simplifying complex conditional logic and improving debugging through enhanced type checking.

Implementing Discriminated Unions for Type-Safe Pattern Matching

To create a discriminated union in TypeScript, you define a common property that distinguishes between different types. This property is usually a literal type, such as a string or a number.

interface Circle {
kind: 'circle';
radius: number;
}

interface Square {
kind: 'square';
sideLength: number;
}

type Shape = Circle | Square;

In this example, the discriminated union Shape can be either a Circle or a Square, each with its own properties. The shared kind property serves as the discriminant, allowing TypeScript to perform type narrowing when you check its value.

This pattern works well with Convex's schema validation system, where you can define validators for different object shapes in your database.

Handling Various Object Types with Discriminated Unions

Discriminated unions help manage multiple object types safely. The discriminant property lets you narrow down the type and access specific properties.

function processShape(shape: Shape) {
if (shape.kind === 'circle') {
console.log(`Circle radius: ${shape.radius}`);
} else if (shape.kind === 'square') {
console.log(`Square side length: ${shape.sideLength}`);
}
}

This example uses the kind property to identify the shape type and access its specific properties. By checking the discriminant value, TypeScript knows exactly which interface the object conforms to, giving you type-safe access to the appropriate properties.

This pattern is similar to how Convex handles different document types in its database, where you can validate different shapes based on a type field.

Defining Discriminated Unions for Easy Type Narrowing

You can simplify type narrowing by defining a discriminated union with a shared discriminant property. This allows you to use the in operator for type narrowing.

interface Admin {
role: 'admin';
permissions: string[];
}

interface User {
role: 'user';
username: string;
}

interface Guest {
role: 'guest';
}

type UserRole = Admin | User | Guest;

function processRole(role: UserRole) {
if ('permissions' in role) {
console.log(`Admin permissions: ${role.permissions}`);
} else if ('username' in role) {
console.log(`User username: ${role.username}`);
} else {
console.log('Guest');
}
}

In this example, we define a discriminated union UserRole with a common role property. We can then check the role property directly for cleaner type narrowing, as TypeScript understands which properties are available based on the discriminant value.

This approach works well when building end-to-end type-safe applications where you need to handle different user roles with their specific data requirements.

Creating Type-Safe Switch-Case Structures with Discriminated Unions

Discriminated unions allow for type-safe switch-case structures by using the discriminant property to determine the object type.

interface Success {
status: 'success';
data: any;
}

interface Error {
status: 'error';
message: string;
}

interface Loading {
status: 'loading';
}

type StatusCode = Success | Error | Loading;

function processStatus(code: StatusCode) {
switch (code.status) {
case 'success':
console.log(`Success data: ${code.data}`);
break;
case 'error':
console.log(`Error message: ${code.message}`);
break;
case 'loading':
console.log('Loading...');
break;
}
}

This example demonstrates using a switch-case statement to handle different status codes, which helps simplify complex conditional logic. The typeof operator can also be useful when working with discriminated unions to check types at runtime.

This pattern is similar to Convex's approach to filtering complex data, where you can handle different data cases efficiently.

Combining Discriminated Unions with Type Guards

You can improve type safety by combining discriminated unions with type guards. Type guards help narrow down the type within a specific scope.

function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}

function processShape(shape: Shape) {
if (isCircle(shape)) {
console.log(`Circle radius: ${shape.radius}`);
} else {
console.log('Not a circle');
}
}

This example uses a type guard isCircle to narrow down the type of the shape object. The keyof operator can be useful when creating flexible type guards for discriminated unions.

When debugging TypeScript code, these type guards provide clearer error messages and more reliable type checking.

Structuring Code with Discriminated Unions

Discriminated unions can help organize code for complex data models. By defining a common discriminant property, you can create a hierarchy of types to narrow down an object's type.

interface Vehicle {
type: 'car' | 'truck' | 'motorcycle';
}

interface Car extends Vehicle {
type: 'car';
doors: number;
}

interface Truck extends Vehicle {
type: 'truck';
capacity: number;
}

interface Motorcycle extends Vehicle {
type: 'motorcycle';
engineSize: number;
}

type VehicleType = Car | Truck | Motorcycle;

function processVehicle(vehicle: VehicleType) {
if (vehicle.type === 'car') {
console.log(`Car doors: ${vehicle.doors}`);
} else if (vehicle.type === 'truck') {
console.log(`Truck capacity: ${vehicle.capacity}`);
} else {
console.log(`Motorcycle engine size: ${vehicle.engineSize}`);
}
}

This example illustrates defining a hierarchy of vehicle types using discriminated unions. The extends keyword is essential here for creating base types with discriminant properties.

This approach aligns with Convex's custom functions pattern, where different function types can share base functionality while maintaining their unique behaviors.

Practices for Clear and Maintainable Code

To improve readability and maintenance when using discriminated unions:

  1. Use descriptive names for the discriminant property (like kind, type, or variant)
  2. Keep discriminant values simple and meaningful
  3. Group related interfaces and types in the same file
  4. Consider using string literal types for better auto-completion
  5. Use exhaustiveness checking to catch missing cases:
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${x}`);
}

function processVehicle(vehicle: VehicleType) {
switch (vehicle.type) {
case 'car':
return `Car with ${vehicle.doors} doors`;
case 'truck':
return `Truck with ${vehicle.capacity}kg capacity`;
case 'motorcycle':
return `Motorcycle with ${vehicle.engineSize}cc engine`;
default:
return assertNever(vehicle); // Type error if we missed a case
}
}

The union types feature is central to discriminated unions, and understanding how to properly structure them improves code organization.

For complex applications, consider combining this pattern with Convex's helpers for traversing relationships between different data types.

Final Thoughts on TypeScript Discriminated Unions

Discriminated unions are a core TypeScript feature that make handling different object types safer and more intuitive. They work especially well for state management, API responses, and complex data modeling. By combining discriminated unions with TypeScript's type narrowing capabilities, you can write code that's both flexible and type-safe.

As you continue working with TypeScript, discriminated unions will likely become one of your go-to patterns for managing complex data flows while maintaining strong type guarantees.