Discriminated Unions

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.

Understanding Discriminated Unions

What are Discriminated Unions?

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.

Basic Syntax

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;

				
			

Explanation:

  • We define three types (Square, Rectangle, and Circle) each with a kind property that acts as the discriminant.
  • Each type has additional properties that are specific to that shape.
    • Square has a size property.
    • Rectangle has width and height properties.
    • Circle has a radius property.
  • We then define a union type Shape that can be any of the three shapes.

Using Discriminated Unions

Type Guards

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
				
			

Explanation:

  • The area function calculates the area of a Shape.
  • We use a switch statement to check the kind property of the shape.
  • Depending on the kind, we calculate the area:
    • For square, the area is size * size.
    • For rectangle, the area is width * height.
    • For circle, the area is Math.PI * radius * radius.
  • We include a default case that throws an error if an unknown shape is provided.
  • The example usage demonstrates how to create instances of Square and Circle and calculate their areas.

Exhaustiveness Checking

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');
  }
}
				
			

Explanation:

  • This function is similar to the previous area function but includes an exhaustiveness check.
  • The default case assigns the shape to a variable of type never. This ensures that all possible cases are handled.
  • If a new shape type is added but not handled in the switch statement, TypeScript will produce a compile-time error.

Advanced Usage

Nested Discriminated Unions

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 } }
				
			

Explanation:

  • We define a new type ColoredShape that includes a color property and a shape property.
  • The shape property can be any of the types defined in the Shape union.
  • In the example usage, we create an instance of ColoredShape with a red square.
  • The output shows the nested structure of the colored shape.

Combining with Other Types

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<T extends Shape>(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
				
			

Explanation:

  • We define a ShapeBase interface with a kind property.
  • We extend ShapeBase to create Square, Rectangle, and Circle interfaces.
  • The Shape type is a union of these interfaces.
  • The calculateArea function is generic and works with any type that extends Shape.
  • The function uses a switch statement to calculate the area based on the kind property.
  • The example usage demonstrates calculating the area for a square and a rectangle.

Practical Applications

State Management

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
				
			

Explanation:

  • We define three states (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.
  • The NetworkRequestState type is a union of these states.
  • In the example usage, we create instances of each state and a handleRequest function to process them.
  • The handleRequest function uses a switch statement to handle each state and log appropriate messages.

Form Handling

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
				
			

Explanation:

  • We define three types of form inputs (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.
  • The FormInput type is a union of these input types.
  • In the example usage, we create instances of each input type.
  • The handleInput function processes each input type using a switch statement to log appropriate messages.

Best Practices

Keep Discriminants Simple

Ensure that the discriminant property is simple and easy to understand. Typically, a string literal is used for clarity and simplicity.

Use Exhaustiveness Checking

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.

Combine with Type Guards

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! ❤️

Table of Contents

Contact here

Copyright © 2025 Diginode

Made with ❤️ in India