Engineering · 4 min read

The power of discriminated unions and exhaustiveness checking in Typescript

Engineers at Fullstory place a strong emphasis on high quality “pre-commit” code reviews. During a recent code review, one of my peers left thorough feedback on a topic that was new to me: discriminated unions and exhaustiveness checking. This feature of Typescript is incredibly powerful and in this article I will be sharing what I learned and why it is useful. In addition, I will be sharing the use case I encountered: setting up a request/response infrastructure around websockets.

What Is A Discriminated Union?

Very broadly, a discriminated union is a pattern that indicates to the compiler all of the possible types that a newly created type can represent. To create a discriminated union, all types that form the union must have the same literal member (boolean, string, number) but a unique value for that literal.

The type Coffee is an example of a discriminated union. The literal member in this case is kind. We can use the value of kind to differentiate between the types that make up Coffee and handle each type differently.

This switch statement, on the literal member of a discriminated union, is called an “exhaustive switch”. The compiler can use the literal member to imply the type in each case statement without the need for type assertions. If we had forgotten the case statement for any of the distinct values, say "latte", the compiler would throw an error that reads ERROR : 'Latte' is not assignable to 'never'. never is a type in typescript that represents a concept in code flow analysis for stuff that should never happen. In an exhaustive switch, once the object reaches the default case it becomes type never. assertExhaustive is a function that takes both a parameter of type never and an optional string message and throws an exception. This is where the power of discriminated unions lies: using the compiler to check at compile time that all types are being handled.

Real World Example: Request/Response Infrastructure in Websockets

Now let’s imagine designing a websocket api where every message the client sends to the server has a corresponding response. In this example, we will use an abstraction for a protocol object with two async methods: sendMessage and handleMessage. sendMessage takes a receiver and a request, sends the request to the receiver, and returns the response from the other end of the websocket. handleMessage is called on MessageEvent and takes a function which responds to the request. We will be using discriminated unions and exhaustiveness checking to make the compiler enforce at compile time that every possible request is handled and that each request returns the correct type of response.

To start, we will define two types of requests with a literal member action. action serves a similar function as an endpoint does in XHR.

The sendMessage and handleMessage methods are set up so that the compiler can use the value of action to determine the request type. To send a message to the other end of the connection (represented as receiver), we would write:

action indicates that we are dealing with a RequestA. Since the compiler knows we are sending a RequestA, it allows us to also pass args. If action was 'b', this wouldn't compile. Now let’s define response types and request/response pairings.

With these defined, we can modify our previous example of sendMessage to await the response and do something with it. In this case, the type of response will be ResponseA, because we're sending a RequestA. We are able to write response.result (which is not defined on type ResponseB) without a type assertion because of discriminated unions.

We have not yet explicitly seen how we are using discriminated unions. The protocol object that we have been referencing to call sendMessage actually takes a generic during instantiation. That generic tells the compiler which types of messages it can handle (a message being a request/response pair).

Now let’s consider how to handle receiving messages on the other end of the connection. By listening for MessageEvent and using handleMessage on a protocol created in the exact same way, we have:

The exhaustive switch here forces the compiler to check that we are handling every type of request or it will throw a compile time error. The compiler also checks that the correct response type is being returned or it will throw a compile time error. So we would get an error if we tried to return [request, { status: 500 }] in case 'a' instead.

Excluding a few abstractions, we have now implemented a simple request/response api using the websocket transport. This api leverages the compiler to make sure that all possible requests are handled and that each request returns the correct type of response.

Want a perfect website or app? Fullstory can help. Request a demo today.

author

Nicholas Fahrenkrog

Software Engineer

Software Engineer at Fullstory, Georgia Tech Alum, Runner (Strawberry Canyon Track Club)