DISCRIMINATED UNIONS IN TYPESCRIPT

2022-07-18
One of the most useful features in functional languages(such as Haskell, Scala or OCaml) are algebraic data types; i.e., types built by the union of other types. Typescript supports ADT by using discriminated unions. In this tutorial we will see what discriminated unions are, how they work and how to use them.

Discriminated Union Types

>
A discriminated union type(also known as tagged union or algebraic data type) \( F \) is a union of at least two types \( P \) and \( K \) that represent the domain of the types assignable to the newly created type \( F \).
All types that form the discriminated union must have a common literal type of unique value, i.e. a value that Typescript can use to discriminate between the union types. The syntax to declare a discriminated union type is the following:
                
interface FooI {
    fooName: string,
    kind: "foo" // Common field
};

interface BarI {
    barTitle: string,
    barQt: number,
    kind: "bar" // Common field
};

interface BazI {
    bazDate: Date,
    kind: "baz" // Common field
};

type myType = FooI | BarI | BazI;                        
                
            
In the example above, the common field is kind, which can be used by the Typescript compiler to narrow down the type of the object that instantiate myType.

Modelling a real world problem

If the previous section was too dense and tedious, keep reading. In this part of the article we will introduce the same exact topic using a practical example.

Let us suppose that we are working on a backend application that is in charge to manage the signup process of new users. The system is able to manage both private and business users. A naive solution to this problem could be the following:
                
enum UserType {
    BusinessUser,
    PrivateUser
};

interface User {
    UserName?: string
    UserSurname?: string,
    BusinessName?: string,
    BusinessSector?: string,
    EstDate?: Date,
    UserType: UserType
};                        
                
            
That is, we define a new type User with all available fields from both private and business user. We can then build new objects in the following way:
                
const ibm: User = {
    BusinessName: "IBM",
    BusinessSector: "Computers",
    EstDate: new Date(1911, 7, 16),
    UserType: UserType.BusinessUser
};

const MrDoe: User = {
    UserName: "John",
    UserSurname: "Doe",
    UserType: UserType.PrivateUser
};
                
            
The main drawback with this solution is that, while both BusinessUser and PrivateUser instantiate the same interface, they have a different structure. This can lead to situation where we no longer have a clean structure of our object; for instance, this solution allows us to define an object with both UserName and BusinessName defined, which does not make any sense.

With discriminated unions, we can model this scenario without enclosing all available fields into a single interface and without losing consistency, here's how:
                
interface BusinessUser {
    BusinessName: string,
    BusinessSector: string,
    EstDate: Date,
    UserT: "business"
};

interface PrivateUser {
    UserName: string,
    UserSurname: string,
    UserT: "private"
};

type User = BusinessUser | PrivateUser;                        
                
            
That is, we are creating a new type(User) as a union of two different interfaces(BusinessUser and PrivateUser). These two interfaces have a common field(UserT), a literal type, that allows Typescript to discriminate between them. The main benefit with discriminated unions is that we now cannot create an inconsistent version of the User type, i.e. the following code:
                
const MrDoe: User = {
    UserName: "John",
    UserSurname: "Doe",
    BusinessName: "MrDoeLtd",
    UserT: "private"
};                        
                
            
yields this error:
>
Error: BusinessName does not exist in type PrivateUser.
While each proper instance of an interface is made by a consistent set of fields:
                
const ibm: User = {
    BusinessName: "IBM",
    BusinessSector: "Computers",
    EstDate: new Date(1911, 7, 16),
    UserT: "business"
};

const MrDoe: User = {
    UserName: "John",
    UserSurname: "Doe",
    UserT: "private"
};                        
                
            

Type narrowing for discriminated unions

In typescript, it is very common to have a function whose parameters are defined using a union of different types. That is:
                
const foo = (x: number | string, y : number) => {
// ...
}                        
                
            
In this case, before being able to access the value of the x parameter, we need to narrow down which type is represented at runtime, otherwise we will get the following error:
>
error TS2365: Operator + cannot be applied to types string | number and number.
To avoid this kind of problem, we can simply check the type of the parameter using the typeof function:
                
const foo = (x: number | string, y: string) => {
    if(typeof x === "number")
        return x + 1;

    return x + y;
}                        
                
            
We can adopt the same mechanism for discriminated unions. Again, suppose to be working on a backend application that deals with both private and business users; a function that retrieve the user's name can be implemented in the following way:
                
const getUser = (user: User) => {
    switch(user.UserT) {
        case "business": return user.BusinessName;
        case "private": return user.UserName + " " + user.UserSurname;
    }
}                                
                
            
As you can see, we narrowed down the user parameter using the UserT field.

Conclusion

Discriminated union types are a powerful mechanism of the Typescript language, they can be used to create a strict model of a real world scenario without losing consistency. They have their roots in the functional paradigm, and they can be used as an alternative to interface inheritance from an object-oriented point of view.