Model Alternatives with Discriminated Union Types in TypeScript

TypeScript’s discriminated union types (aka tagged union types) allow you to model a finite set of alternative object shapes in the type system. The compiler helps you introduce fewer bugs by only exposing properties that are known to be safe to access at a given location. This lesson shows you how to define a generic Result<T> type with a success case and a failure case. It also illustrates how you could use discriminated unions to model various payment methods.

Code

You must be a Member to view code

Transcript

Let's say we have the stripe parse in function, and we want to define an explicit return type. Let's first take a look at how the function is implemented. It accepts a single parameter of type string, and then it checks whether the string looks like a positive or negative integer. We allow an optional leading minus sign and then require one or more decimal digits.

If the text matches the pattern, we want to return an object in which we communicate that the conversion was successful. Of course, we also want to attach the converted value.

If the text does not match the pattern, we want to return an object with a similar shape. We want to communicate that the conversion was not successful, and we also want to give a reason why.

Let's now take a stab at defining an explicit return type for this function. I'm creating a type alias called result which describes the shape of the returned object. It's going to need a success property of type Boolean, an optional value property for the success case, and an optional error property for the failure case.

With this type definition in place, we can now add an explicit return type annotation. Notice that our function still type checks correctly, which is great. What's not so great about the result type is that it's not restrictive enough. Both value and error and optional, so it's perfectly type correct if we don't specify either.

The type system also currently doesn't prevent us from returning incorrect values for the success property, so we can return success false and a value at the same time.

We can improve our result type by turning it into a discriminated union type. In other programming languages, discriminated unions are also known as tagged unions or sum types. They allow us to model alternatives.

In our case, we have exactly two alternatives. On the one hand, we want to model the success case where the success property is true and we have a value of type number. On the other hand, we want to model the error case where the success property is false and we have an error property of type string.

Every discriminated union needs a so-called discriminant property or simply tag to distinguish between the various alternatives. That discriminant property must of a literal type. In our case, we're using the success property as a discriminant which is of a Boolean literal type.

Also notice that our value and error properties are no longer optional. In the success case, we definitely have a number, and in the error case, we definitely have a string. Neither one is optional, so it's no longer type correct if I comment out this line below.

Before we move on, there is one little refactoring that I'd like to make. That is to make the result type generic. The success case is not really specific to numbers, so let's go ahead and replace number by a generic type T.

Let's see what happens now if we call the TryParse int function. The local variable result is inferred to be of type result number because that's the return type that we specified.

Next up, we will check whether the conversion was successful by reading the success property. Within the if statement, something interesting happens. The only alternative of our discriminated union type that would make us enter this if statement is the first one where the success property is true and we have a value property.

Therefore, TypeScript can narrow the type of the result variable accordingly and present us with the available properties. This is why we can access the value property without any problems, but we do not see the error property in the autocompletion list. In fact, we would even get a type error if we tried to access it.

Within the else clause, we see exactly the opposite. Here result is narrowed to the second alternative. This means that we can access the error property but not the value property.

Using discriminated unions this way can really help you write fewer bugs. The type system forces you to check the discriminant property first before it gives you access to the individual properties. Note that for all of this to work properly, you should have these strict all checks compiler option set to true.

Here's another example scenario in which you can use discriminated unions. Imagine you want to model payment methods, and you want to accept cash, PayPal, and credit cards.

Each payment method can have different associated properties. For example, a PayPal payment is associated with a specific account email, and a credit card payment is associated with a card number and a security code. All of these payment methods define a common property called kind which is of a string literal type and serves as the discriminant.

We can now define the actual discriminated union type, called payment method, and union together all of these types. Once we've done that, we can define a function that accepts a payment method and returns a human readable description of it. We do this by switching over the kind property.

Recall that the kind property is the discriminant here. Also notice that, as I'm typing the cases, I get autocompletion for the string literals. The kind property can only be the string cash, the string PayPal, or the string credit card and nothing else. Now that we've covered all the cases, our code type checks correctly.

Finally, within each case of the switch statement, the method is narrowed to the respective alternative of the discriminated union. For instance, within the PayPal case, we can access the email property without any problems.