Model Alternatives with Discriminated Union Types in TypeScript

Marius Schulz
InstructorMarius Schulz

Share this video with your friends

Send Tweet
Published 4 years ago
Updated 3 years ago

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.

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.

Thorben
Thorben
~ 4 years ago

Hi Marius, this is a really great course!

I have an issue with discriminated union types.

I want to discriminate the props of my React component by visibleListType property. However if I am now passing the prop visibleListType={"ListView'} and e.g. keyExtractor={item => item.userId} to the AlwaysVisibleList component, I'd expect the TS complier to warn me about using the keyExtractor prop. This is because the keyExtractor is part of the FlatListProperties, but not the ListViewProperties.

Do you know what I am doing wrong here? I have strictNullChecks enabled.

interface OwnProps {
  customInsetWhenKeyboardIsHidden?: number;
  customInsetWhenKeyboardIsShown?: number,
}

type VisibleListType = 'ListView' | 'FlatList' | 'SectionList' ;

interface SectionListProps extends SectionListProperties<any>, OwnProps {
  visibleListType: VisibleListType;
}

interface FlatListProps extends FlatListProperties<any>, OwnProps {
  visibleListType: VisibleListType;
}

interface ListViewProps extends ListViewProperties, OwnProps {
  visibleListType: VisibleListType;
}

type Props =
  | SectionListProps
  | FlatListProps
  | ListViewProps

interface State {
}

class AlwaysVisibleList extends React.Component <Props, State> {
 \*...*\
}
Marius Schulz
Marius Schulzinstructor
~ 4 years ago

@Thorben: Some types are missing in your code example (e.g. SectionListProperties<T>), but from what I can see, you're not specifying a different discriminant for each of your props interfaces — you're defining a union type once ('ListView' | 'FlatList' | 'SectionList'), which is then shared by all props interfaces.

This is not how discriminants (aka tags) are meant to be used. Every props interface should define a property of the same name and a unique literal type, which is then used to differentiate between the possible cases. That doesn't work if you're using the same union type for every case.

Actum
Actum
~ 3 years ago

Hello Marius,

Thank you for your course.

I've found very tricky part.

when you write in the switch expression method.kind (like in your example) it works perfect.

but when I use destructurisation const { kind } = method; and put kind to the expression, the compiler complains to method.email: Property 'email' does not exist on type 'PaymentMethod'. Property 'email' does not exist on type 'Cash'.

Could you explain whats wrong?

Thank you

Marius Schulz
Marius Schulzinstructor
~ 3 years ago

@Actum: The TypeScript compiler only narrows the type of the method parameter if you're checking method.kind directly. It does not track that you've stored the value of method.kind within the kind local variable. You'll have to stick to method.kind to have the compiler narrow the type.

yu
yu
~ 3 years ago
Argument of type '{ kind: string; email: string; }' is not assignable to parameter of type 'PaymentMethod'.
  Type '{ kind: string; email: string; }' is not assignable to type 'CreditCard'.
    Property 'cardNumber' is missing in type '{ kind: string; email: string; }'.
const myPayment: {
    kind: string;
    email: string;
}

ts complained above, Could you explain whats wrong? Thanks

Marius Schulz
Marius Schulzinstructor
~ 3 years ago

@yu: Could you post a small code example that produces the error you're talking about?

Ken Snyder
Ken Snyder
~ 3 years ago

Is there amy way to have a class implement a discriminated union? It seems to throw an error and i’ve read elsewhere of people having this issue. Would be a shame to lose this type specificity in a class where it is readily available in a POJO hash/dictionary.

Marius Schulz
Marius Schulzinstructor
~ 3 years ago

@Ken: No, that's not possible as far as I am aware. You'll have to stick with POJOs if you want to use discriminated unions.