Headshot of Marius Schulz
Marius Schulz Front End Engineer

TypeScript 2.0: Tagged Union Types

TypeScript 2.0 implements a rather useful feature: tagged union types, which you might know as sum types or discriminated union types from other programming languages. A tagged union type is a union type whose member types all define a discriminant property of a literal type.

Because the above definition is rather theoretical, we're going to be looking at two examples that illustrate how tagged union types would be used in practice.

Modelling Payment Methods with Tagged Union Types

Let's say we want to model the following payment methods that users of a system can choose from:

  • Cash without further information,
  • PayPal with a given email address, or
  • Credit card with a given card number and security code.

For each of these payment methods, we can create a TypeScript interface:

interface Cash {
  kind: "cash";
}

interface PayPal {
  kind: "paypal";
  email: string;
}

interface CreditCard {
  kind: "credit";
  cardNumber: string;
  securityCode: string;
}

Note that, in addition to the required information, each type has a kind property — the so-called discriminant property. It's of a string literal type in each case here. We'll look at the discriminant property in a minute.

Let's now also define a PaymentMethod type that is the union of the three types we just defined. This way, we're stating that every payment method must have exactly one of the three given constituent types:

type PaymentMethod = Cash | PayPal | CreditCard;

Now that our types are in place, let's write a function that accepts a payment method and returns a human-readable description of it:

function describePaymentMethod(method: PaymentMethod) {
  switch (method.kind) {
    case "cash":
      // Here, method has type Cash
      return "Cash";

    case "paypal":
      // Here, method has type PayPal
      return `PayPal (${method.email})`;

    case "credit":
      // Here, method has type CreditCard
      return `Credit card (${method.cardNumber})`;
  }
}

First of all, notice how few type annotations the function contains — just a single one for its method parameter! Besides that, the body of the function is pure ES2015 code.

Within each case of the switch statement, the TypeScript compiler narrows the union type to one of its member types. For instance, within the "paypal" case, the type of the method parameter is narrowed from PaymentMethod to PayPal. Therefore, we can access the email property without having to add a type assertion.

In essence, the compiler tracks the program control flow to narrow the tagged union types. Other than switch statements, it understands conditions as well as the effects of assignments and returns:

function describePaymentMethod(method: PaymentMethod) {
  if (method.kind === "cash") {
    // Here, method has type Cash
    return "Cash";
  }

  // Here, method has type PayPal | CreditCard

  if (method.kind === "paypal") {
    // Here, method has type PayPal
    return `PayPal (${method.email})`;
  }

  // Here, method has type CreditCard
  return `Credit card (${method.cardNumber})`;
}

This degree of control flow analysis makes working with tagged union types really smooth. With minimal TypeScript syntax overhead, you can write almost plain JavaScript and still benefit from type checking and code completion. A pleasant editing experience, indeed!

Modelling Redux Actions with Tagged Union Types

Another use case where tagged union types really shine is when you're using Redux in your TypeScript applications. Let's construct another quick example, consisting of a model, two actions, and a reducer for — you guessed it — a todo application.

Here's a simplified Todo type that represents a single todo. Note how we're using the readonly modifier to have the TypeScript compiler check for unintended property mutation:

interface Todo {
  readonly text: string;
  readonly done: boolean;
}

Users can add new todos and toggle the completion status of existing ones. For these requirements, we're going to need two Redux actions, which we can type as follows:

interface AddTodo {
  type: "ADD_TODO";
  text: string;
}

interface ToggleTodo {
  type: "TOGGLE_TODO";
  index: number;
}

As in the previous example, a Redux action can now be modelled as the union of all actions our application supports:

type ReduxAction = AddTodo | ToggleTodo;

In this case, the type property serves as the discriminant property and follows the naming scheme common in Redux. Let's now add a reducer which works with these two actions:

function todosReducer(
  state: ReadonlyArray<Todo> = [],
  action: ReduxAction
): ReadonlyArray<Todo> {
  switch (action.type) {
    case "ADD_TODO":
      // action has type AddTodo here
      return [...state, { text: action.text, done: false }];

    case "TOGGLE_TODO":
      // action has type ToggleTodo here
      return state.map((todo, index) => {
        if (index !== action.index) {
          return todo;
        }

        return {
          text: todo.text,
          done: !todo.done
        };
      });

    default:
      return state;
  }
}

Again, only the function signature contains type annotations. The remainder of the code is plain ES2015 and in no way specific to TypeScript.

We're following the same logic as in the previous example here. Based on the type property of the Redux action, we compute the new state without modifying the existing one. Within the cases of the switch statements, we can access the text and and index properties specific to each action type without any type assertions.

This post is part of the TypeScript Evolution series:

  1. TypeScript 2.0: Non-Nullable Types
  2. TypeScript 2.0: Control Flow Based Type Analysis
  3. TypeScript 2.0: Acquiring Type Declaration Files
  4. TypeScript 2.0: Read-Only Properties
  5. TypeScript 2.0: Tagged Union Types
  6. TypeScript 2.0: More Literal Types
  7. TypeScript 2.0: The never Type
  8. TypeScript 2.0: Built-In Type Declarations
  9. TypeScript 2.1: async/await for ES3/ES5
  10. TypeScript 2.1: External Helpers Library
  11. TypeScript 2.1: Object Rest and Spread
  12. TypeScript 2.1: keyof and Lookup Types
  13. TypeScript 2.1: Mapped Types
  14. TypeScript 2.1: Improved Inference for Literal Types
  15. TypeScript 2.1: Literal Type Widening
  16. TypeScript 2.1: Untyped Imports
  17. TypeScript 2.2: The object Type
  18. TypeScript 2.2: Dotted Properties and String Index Signatures
  19. TypeScript 2.2: Null-Checking for Expression Operands
  20. TypeScript 2.2: Mixin Classes
  21. TypeScript 2.3: Generic Parameter Defaults
  22. TypeScript 2.3: The --strict Compiler Option
  23. TypeScript 2.3: Type-Checking JavaScript Files with --checkJs
  24. TypeScript 2.3: Downlevel Iteration for ES3/ES5
  25. TypeScript 2.4: String Enums
  26. TypeScript 2.4: Weak Type Detection
  27. TypeScript 2.4: Spelling Correction
  28. TypeScript 2.4: Dynamic import() Expressions
  29. TypeScript 2.5: Optional catch Binding
  30. TypeScript 2.6: JSX Fragment Syntax
  31. TypeScript 2.7: Numeric Separators
  32. TypeScript 2.7: Strict Property Initialization
  33. TypeScript 2.8: Per-File JSX Factories
  34. TypeScript 2.8: Conditional Types
  35. TypeScript 2.8: Mapped Type Modifiers