Well-designed types improve auto-completion in your code editor, reduce the chance of bugs and make your code more understandable to others.
We will look at some real-life examples that will help you design better types.
Let's say you have users in your application, and each user can be one of three roles: owner
, editor
, and viewer
. These are all string
values, so using string
as the type might feel like a sensible choice.
type User = {
role: string;
}
const user: User = {
role: 'editor' // // TypeScript accepts any string value here,
// even values that the rest of our application wouldn't recognise.
}
if (user.role === 'editr') {
// ...
}
In the example above, we used string
as the type for user roles, but as you can see that doesn't protect us from typos, nor does it help our editor auto-complete our code. And if someone is introduced to our codebase, they will have no way of knowing the allowed values just by looking at this code.
type User = {
role: 'owner' | 'editor' | 'viewer';
}
const user: User = {
role: 'editor'
}
if (user.role === 'editr') {
// ...
}
/**
* ✅ This comparison appears to be unintentional because
* the types '"owner" | "editor" | "viewer"' and
* '"editr"' have no overlap.
*/
By defining the type in a more narrow way, TypeScript will let us know whenever an assignment or a comparison seems unintentional. Given that user.role
can only be one of the allowed values, any values that are not part of that union cannot possibly be there.
Well-defined types should match reality. A lot of times in real-world applications, there are properties that follow each other. For example, you might be tracking orders and each one has a status
. If the status is cancelled, you might have additional properties such as a cancellationReason
and a timestamp that shows when the order was cancelled.
The straightforward approach for defining types doesn't account for such relationships between properties. Let's look at an example to show how the default approach is far from ideal.
type OrderStatus = 'active' | 'completed' | 'cancelled';
type Order = {
id: number;
amount: number;
status: OrderStatus
cancelledAt?: Date;
cancellationReason?: string;
};
const order = getOrderById(1);
if (order.status === 'cancelled') {
const cancellationReason = order.cancellationReason;
// According to TypeScript this is `string | undefined`
const cancelledAt = order.cancelledAt;
// According to TypeScript this is `Date | undefined`
console.log(cancellationReason, cancelledAt);
}
If we wanted to call a function that accepts a string
, we wouldn't be able to do that without using type assertions or "if-checking" the value of cancellationReason
. We logically know according to our application logic that this should be the case, but we need to use a slightly different approach so that our types represent a valid state.
type OrderStatus = 'active' | 'completed' | 'cancelled';
// We define all common properties under `BaseOrder`
type BaseOrder = {
id: number;
amount: number;
}
// We introduce different types for each status, that combines
// properties from BaseOrder and adds any properties that are
// different.
type ActiveOrder = BaseOrder & {
status: 'active';
}
type CompletedOrder = BaseOrder & {
status: 'completed';
}
// Cancelled orders have additional properties that we know are
// defined, so we define them under `CancelledOrder`
type CancelledOrder = BaseOrder & {
status: 'cancelled';
cancelledAt: Date;
cancellationReason: string;
}
// And finally, `Order` is a union of the above types
type Order = ActiveOrder | CompletedOrder | CancelledOrder;
const order = getOrderById(1);
// TypeScript knows that `order` is one of
// ActiveOrder | Completed | CancelledOrder
if (order.status === 'cancelled') {
// If we assert that the order.status is `cancelled`,
// TypeScript is smart enough to know that `order`
// is a CancelledOrder, so the type within the `if` block
// gets narrowed.
const cancellationReason = order.cancellationReason; // string
const cancelledAt = order.cancelledAt; // Date
}
The approach of using discriminated unions allows us to handle different types of orders that can have somewhat different properties in a type-safe manner. When we check the order.status
, TypeScript is able to automatically narrow down the type of order
and safely know which values are available along with their types. The end result is safer and more predictable code that doesn't require additional checking once we have checked the order.status
.
Let's assume that we are developing a photo application where you can upload your photographs. Each photograph can also be geo-tagged to specific coordinates.
In some applications you might see this defined the following way, where lat
and lng
represents coordinates whenever a photo is geo-tagged.
type MediaUpload = {
mediaUrl: string;
lat?: number;
lng?: number;
}
The problem with this approach is that we know that when lat
is defined, then lng
should also be defined. This means that our type, as it is right now, does not always represent a valid state. According to this type, you could have lat
, without having lng
.
Latitude and longitude fit into a logical "group" and are always defined together. We can define coordinates
as an object, to ensure that our type always represents a valid state.
type MediaUpload = {
mediaUrl: string;
coordinates?: {
lat: number;
lng: number;
};
};
In this example, coordinates
might be undefined when the photo is not geo-tagged, but when it's defined then both lat
and lng
are available.
Whenever you have properties that are defined or undefined at the same time, then you should define them as optional or nullable objects. Not only does this make your code type-safe, it also logically groups related properties which enhances readability.