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
StartedCourseand anUnstartedCourse. Started courses have alastInteractionTime, but unstarted courses don't. Accessing alastInteractionTimeon an unstarted course is a type error.>
type StartedCourse = {started: truelastInteractionTime: 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:
2000
That may be a surprise.
Courseis a union type, but only one side of the union has alastInteractionTimeproperty. Why did the compiler let us accesslastInteractionTimeif only one side of the union has it?The compiler can see that our variable of type
Coursehad the propertystarted: true. Within the union type, onlyStartedCoursehasstarted: true, so the compiler knows that our variable is actually aStartedCourse. That means that it's safe to accesslastInteractionTime, 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'.
Look closely at that error message. We declared our variable as a
Course, but TypeScript's error message refers to it as anUnstartedCourse. That happens for the same reason that we discussed above: the compiler can see thatstartedisfalse, so it must be anUnstartedCourse.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'.
Why is it called a discriminated union? First, it's the union of two types:
StartedCourseandUnstartedCourse. Second, the boolean flagstarteddiscriminates between the two types; it's the discriminator. Ifstartedis true, then it's aStartedCourse; otherwise, it's anUnstartedCourse.Discriminated unions are a major feature of TypeScript. In JavaScript, we might accidentally try to access the
lastInteractionTimeof an unstarted course. That will give usundefined. When you seeundefinedshow 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
lastInteractionTimeof an unstarted course. The bug becomes impossible because the compiler makes sure that we follow the types.