Execute Program

Everyday TypeScript: Discriminated Unions

Welcome to the Discriminated Unions lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • Software systems often need to track data in one of multiple states. For example, the course that you're doing right now is "started": you've finished at least one lesson. However, there are probably other courses that you haven't started.

  • We can model this by using a discriminated union between a StartedCourse and an UnstartedCourse. Started courses have a lastInteractionTime, but unstarted courses don't. Accessing a lastInteractionTime on an unstarted course is a type error.

  • >
    type StartedCourse = {
    started: true
    lastInteractionTime: Date
    };
    type UnstartedCourse = {
    started: false
    };
    type Course = StartedCourse | UnstartedCourse;
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const course: Course = {
    started: true,
    lastInteractionTime: new Date(2000, 1, 1)
    };
    course.lastInteractionTime.getFullYear();
    Result:
    2000Pass Icon
  • That may be a surprise. Course is a union type, but only one side of the union has a lastInteractionTime property. Why did the compiler let us access lastInteractionTime if only one side of the union has it?

  • The compiler can see that our variable of type Course had the property started: true. Within the union type, only StartedCourse has started: true, so the compiler knows that our variable is actually a StartedCourse. That means that it's safe to access lastInteractionTime, so the compiler allows it.

  • This is a kind of type inference, even though we provided a type annotation! The compiler always respects our type annotations, but it also infers additional type information when possible.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const course: Course = {
    started: false,
    };
    course.lastInteractionTime.getFullYear();
    Result:
    type error: Property 'lastInteractionTime' does not exist on type 'UnstartedCourse'.Pass Icon
  • Look closely at that error message. We declared our variable as a Course, but TypeScript's error message refers to it as an UnstartedCourse. That happens for the same reason that we discussed above: the compiler can see that started is false, so it must be an UnstartedCourse.

  • A discriminated union must hold exactly one of the two unioned types. We can't mix and match components of the two types.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const course: Course = {
    started: false,
    lastInteractionTime: new Date(2000, 1, 1),
    };
    course.lastInteractionTime.getFullYear();
    Result:
    type error: Object literal may only specify known properties, and 'lastInteractionTime' does not exist in type 'UnstartedCourse'.Pass Icon
  • Why is it called a discriminated union? First, it's the union of two types: StartedCourse and UnstartedCourse. Second, the boolean flag started discriminates between the two types; it's the discriminator. If started is true, then it's a StartedCourse; otherwise, it's an UnstartedCourse.

  • Discriminated unions are a major feature of TypeScript. In JavaScript, we might accidentally try to access the lastInteractionTime of an unstarted course. That will give us undefined. When you see undefined show up in a web application's UI, it's often this kind of bug.

  • In TypeScript, we can use the type definitions shown above. This makes it impossible to ever access the lastInteractionTime of an unstarted course. The bug becomes impossible because the compiler makes sure that we follow the types.