Learn type design

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.

Use the narrowest type that applies

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.

Types should represent valid states

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); }
Screenshot or image included in articleScreenshot or image included in article

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.

Group properties that are defined at the same time

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.

Copyright ©2024 Bestpractices.tech. All rights reserved.