It is quite common when modeling a real-life problem to have constraints such as having a user account with either a nickname or an email, an avatar or an emoji. TypeScript gives us tools to model this kind of data.
What options do we have?
type Boss = {
president?: Official;
king?: Monarch;
}
type Official = {
name: string,
age: number,
}
type Monarch = {
name: string,
title: string,
}
This example adds two optional properties to describe that situation. Notice
that to display a Boss’s name, we have to check for the presence of at least one
attribute. A boss
object might have either a president
or a king
, but the
shape of the data does not fit that description. Having n
optional properties
isn’t the best way to describe the presence of at least one of them.
The Boss
type aims to represent it can be either an official or a
monarch. Having both optional properties opens up to many invalid
states. Such as the possibility of having none of the attributes, or having them
all at the same time.
If we model a solution with multiple optional properties, navigating the codebase would come with multiple null object checks.
const bossName({ president, king }: Boss): string => {
if(president) {
return president.name;
} else if(king) {
return king.name;
}
// Still the type definitions allows a case where both properties might
// be null so we must plan for this
else {
return "Apparently total anarchy";
}
}
How might we avoid this mismatch between the state of the problem and our domain modeling? Typescript allows us to combine different types using the union operator(|). When used along with type aliases to define a new type is known as union types.
type Boss = Official | Monarch;
const bossName = (boss: Boss): string => {
return boss.name;
}
This works particularly well here since both types have the same property.
Type guards πββοΈ
What about when we have to reach for other attributes? The check for nullability becomes a type assertion.
const bossDescription = (boss: Boss): string => {
if((boss as Official).age) {
const official = (boss as Official);
return `${official.name}, ${official.age} years old`;
} else if((boss as Monarch).title) {
const monarch = (boss as Monarch);
return `${monarch.title}, ${monarch.name}`;
} else {
return 'The anarchy continues';
}
}
The as operator instructs the compiler to treat the variable to the left
as the type to the right. In this particular example, we are converting an
argument of type Boss
into an Official
first and later into a Monarch
. One
thing to notice with this operand is that we are using it twice in the if block.
First, to assert that we are dealing with an Official
and then to access the
age
property in the object. Given that we guarded that branch feels
unnecessary to specify again that we are in the presence of an instance of an
object of type Official
.
The final type assertion feels unnecessary since there should be only two possibilities. Since we are telling the compiler to trust us on this one(by using type assertions), we need to keep guiding it after that.
More type guards
There are other ways to check the type: one of them is using type predicates with the is operand.
const isMonarch(boss: Boss) boss is Monarch {
return (boss as Monarch).title !== undefined;
}
This approach works, but it feels like we are doing the compiler’s job, the compiler is probably better at figuring this out than we are. As long as we stick to annotating the types from our queries, using type guards feels more robust.
In you go
Another tool at our disposal is the in operator. We can assert the presence of the property in our instance before using it.
const bossDeescription = (boss: Boss): string => {
if ("age" in boss) {
return `${boss.name}, ${boss.age} years old`;
}
return `${boss.title}, ${boss.name}`;
}
Using the in operator confirms that the properties we check are present and non-optional. However, the opposite is true in both counts. After this predicate, the remaining type either does not have the checked property, or it is undefined.
One downside to this alternative is that the action of picking the right property involves insider knowledge about the type. Using a property that was added to describe the object itself, to differentiate two types is far from ideal. There’s gotta be a better way!
Introducing Discriminated Unions:
A solution is using a combination of string literal types, union types,
type guards, and type aliases. To discriminate one type from the other in a
union, we add a __typename
property for that goal. Since it’s purpose is to
differentiate, the value should be a constant. In the following example we use a
string literal or singleton type:
type Official = {
__typename: 'Official',
name: string,
age: number,
}
type Monarch = {
__typename: 'Monarch',
name: string,
title: string,
}
type Boss = Official | Monarch;
const bossDescription = (boss: Boss): string => {
if (boss.__typename === 'Official') {
return `${boss.name}, ${boss.age} years old`;
}
return `${boss.title}, ${boss.name}`;
}
Now there is a straightforward and self-composed way of distinguishing the types on our aliased union. Without having to peek into the type definition and not leaving us exposed to future side effects from valid changes.
Conclusion
When we model a problem that has property A or property B Typescript has
an impressive array of options to solve the problem. Making sure we evaluate the
options is our part. By using the in
operator, we are relying on attributes
added with a purpose far from differentiating this type from the others. When
using a random attribute from a type that was not added to make it unique, we
are violating the contract agreed upon by consuming it. The as
operator
demands to import all of the type definitions just for making the assertion
adding unnecessary dependencies to our code.
Discriminated Unions combine more than one technique and create self-contained types. Types that carry all the information to use them without worrying about name collisions or unexpected changes.