Use inference to make your code DRY

TypeScript can typically infer the types of variables and the return types of functions, without you needing to explicitly define one.

There are cases where you want to explicitly define certain types, but mostly you should let inference do its job. It makes your code easier to read and requires less repetition.

Inference in functions

In this first example, I have defined the return type explicitly. But TypeScript is able to infer the return type even without defining it. If you try out the code below, and hover over both variables in your editor, you'll see that TypeScript knows in both cases that the return type is a boolean. This means that the `: boolean` type in the first function is redundant.

// Defining return type explicitly const isGreaterThanFive = (num: number): boolean => { return num > 5; }; // const isGreaterThanFive: (num: number) => boolean // Allowing TypeScript to infer the return type const isGreaterThanTen = (num: number) => { return num > 10; }; // const isGreaterThanTen: (num: number) => boolean const greaterThanFive = isGreaterThanFive(6); // const greaterThanFive: boolean const greatertThanTen = isGreaterThanTen(11); // const greatertThanTen: boolean

The example above is quite simple, but TypeScript is able to correctly infer the type in more complex cases as well. Let's go through some examples together.

Union Types

When a function returns many different values, TypeScript will infer the type as a union of these values. This comes with many benefits, as you have a much better idea what to expect when consuming the function.

const getLabelForType = (type: number) => { switch (type) { case 0: return "Orange"; case 1: return "Apple"; case 2: return "Cranberry"; default: return "Unknown Juice"; } }; // const getLabelForType: (type: number) => "Orange" | "Apple" | "Cranberry" | "Unknown Juice" if (getLabelForType(0) === 'Peach') { // ... } /* This comparison appears to be unintentional because the types '"Orange" | "Apple" | "Cranberry" | "Unknown Juice"' and '"Peach"' have no overlap. */
Returning typed objects

When TypeScript has inferred an object's type and that object is returned, the return type corresponds to the inferred object type. This holds true whether payload has an explicit type or is derived from a function with a known (inferred or explicit) type. Once a type is established, it is consistently applied across functions, which is a valuable feature in TypeScript.

Unfortunately, the inverse is also true. any-typed values can also "travel", so we need to be careful about how we use them. (You can read more about this in the Don't disable type checks page)

const createPayload = (name: string, id: string) => { const payload = { date: new Date(), name, id, }; return payload; }; /** * const createPayload: (name: string, id: string) => { * date: Date; * name: string; * id: string; * } */

Inference in variable definitions

The same logic can apply when defining variables as well, but this comes with a few caveats as we'll see in the examples below.

let num = 5; // let num: number let bool = false; // let bool: boolean let str = "hello"; // let str: string const num2 = 5; // const num2: 5 const bool2 = false; // const bool2: false const str2 = "hello"; // const str2: "hello"

When I use `let` to define variables, the inferred types match what you'd expect. When I define constants using `const`, the inferred types are much tighter than when I use `let`. The reason why this happens is because constants cannot change, and as such will keep the tightest possible set of values as their type.

Use functional approach to maintain type inference

A lot of times we have typed data and we want to perform various manipulations to shape the data the way we want. Using a functional approach helps us maintain type inference, while keeping our code DRY and easy to read.

First, let's look at an approach that seems straightforward, but doesn't work.

// We have a list of users, for the sake of simplicity they only have 2 properties. const users = [ { name: 'Robo', id: 1, }, { name: 'Glen' id: 2, } ]; // Let's say we create an array of userIds const userIds = []; // And now we try to loop through our users to get just their IDs // into the array. for (const user of users) { userIds.push(user); // ❌ Argument of type 'number' is not assignable to parameter of type 'never' }

As we've mentioned earlier, when using `const`, TypeScript will infer the tightest possible set that fits the variable definition. When assigning an empty array, the tighest possible set is `never[]`. So when we try to push a `number` to an array of type `never[]`, TypeScript is right to complain.

The fix is very simple, and that is to explicitly define a type for the array when we first assign a value.

const userIds: number[] = []; // We're telling TypeScript that this is an array that contains numbers, // that just so happens to be empty right now.

This also seems redundant though, because TypeScript already knows that `id` is a number (it has inferred it), so there shouldn't be a need to be so explicit. That's where functional constructs come in, and allow inferred types to flow from one object to another.

const userIds = users.map(user => user.id); // const userIds: number[]

TypeScript is smart enough to understand that since `user.id` is a number, and we're mapping through an array of users and returning the `id`, that we'll end up with an array of numbers (`number[]`). Use functional constructs (map, reduce, slice, etc.) to avoid defining types explicitly and maintain inferrence across different objects and method calls.

Does that mean that I should never use explicit types?

No. It doesn't.

There are different cases where using explicit types makes a lot of sense.

API Calls and unknown return types

When making APIs calls, reading files or in other cases where you know the expected types but TypeScript doesn't, it makes sense to define explicit return Types.

// ❌ Bad example, because we get `Promise<any>` which will "travel" throughout our code const getUsers = async () => { const response = await fetch('https://jsonplaceholder.typicode.com/users'); const users = await response.json(); return users; } // If we don't define a return type, then this will be resolved as const getUsers: () => Promise<any> type User = { id: number; name: string; }; // ✅ Whenever the Promise is resolved, our code will know to expect an array of Users (User[]). const getUsers = async (): Promise<User[]> => { const response = await fetch("https://jsonplaceholder.typicode.com/users"); const users = await response.json(); return users; };
When building complex objects

Let's say you want to return information about an order that's been made on your website. The information lives in a few different places in your database and in your payment processor, so in order to return the right object without missing values or incorrectly-typed values, you can use explicit types to ensure that your function is returning what you want it to.

This can be especially important if you expect other services or clients to consume this information and it needs to fit into a specific format.

type Product = { id: string; name: string; }; type Payment = { id: string; amount: number; }; const mapOrder = ( orderId: string, product: Product, payment: Payment ) => { return { orderId, product: { name: product.name }, payment: { amount: payment.amount } }; };

If you do not define a specific return type, TypeScript will be happy with whatever you're returning. In certain cases, a frontend or a different service depend on the output of your service, so you want to catch any mistakes that risk breaking the contract between your services.

When you use an explicit type, TypeScript will alert you when the object you're returning is missing properties or certain properties are mistyped.

type OrderResponsePayload = { order: { id: string; }; productName: string; paymentAmount: number; }; const mapOrder = ( orderId: string, product: Product, payment: Payment ): OrderResponsePayload => { return { orderId, // ❌ Object literal may only specify known properties, // but 'orderId' does not exist in type 'OrderResponsePayload'. // Did you mean to write 'order' product: { name: product.name }, payment: { amount: payment.amount } }; };

TypeScript will correctly let us know when the return type doesn't match what we expected. Obviously, this is a simple example, But in real-life applications you will frequently need to combine or map different objects in a different shape for consumption elsewhere, and using explicit types comes in handy to ensure that we're returning the right thing.

Copyright ©2024 Bestpractices.tech. All rights reserved.