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
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:>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 \).
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:
While each proper instance of an interface is made by a consistent set of fields:>Error:BusinessName
does not exist in typePrivateUser
.
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:
To avoid this kind of problem, we can simply check the type of the parameter using the>error TS2365: Operator+
cannot be applied to typesstring | number
andnumber
.
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.