This page looks best with JavaScript enabled

In-Depth Look at TypeScript Generics: Part 2 — Advanced Inference

 ·  ☕ 8 min read  ·  ✍️ Iskander Samatov

In Depth Look at TypeScript Generics Part 2 Advanced Inference


Welcome to the second post in our series about TypeScript generics! In this post, we’ll take a closer look at TypeScript inference. We’ll discuss topics like advanced inference, mapped, and conditional types.

If you haven’t already, you should read the first part of the series about the basics of generics to follow along better.

Advanced inference in TypeScript

By combining generics with type inference, we can create advanced types built on top of each other. By creating new types in this way, you can create a robust type system for your project that is easy to maintain because all the children types will automatically adopt the change in their parents.

You may find the syntax for advanced type inference a bit tricky at first, but with a little practice, you should be able to grasp it.

Let’s start with the mapped types.

Mapped types

Mapped types derive from other types. When we combine mapped types with generics, we can form powerful and flexible type definitions.

Creating new types using the keyof is one example. In the first post , we briefly touched on the keyof. Now let’s explore it more in-depth.

keyof is a TypeScript operator that lets you derive new types based on the properties of others. It takes an input type and returns a new one with all of the properties of the original.

Here’s an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type KeysOfObject<T> = keyof T;

type User = {
  id: number;
  name: string;
  address: string;
}

type UserKeys = KeysOfObject<User>

const accessUser = (user: User, key:UserKeys) => {
  return user[key]
}

const user = {
  id: 1,
  name: "John Doe",
  address: "private"
}

accessUser(user, "address");
accessUser(user, "SSN") // ERROR: Argument of type '"SSN"' is not assignable to parameter of type 'keyof User'.

We use keyof in a generic type KeysOfObject that we can use to create other mapped types. We use KeysOfObject to create a new UserKeys type based on the properties of User.

We get an error on line accessUser(user, "SSN"). The reason for this is that we used the type UserKeys to make sure our function only accepts existing user fields.

Creating mapped types is such a fundamental feature that TypeScript added utility types to the language. These utility types like Partial, Readonly, Pick and others make it easier to create custom mapped types.

These utility types use generics under the hood to work with the types they receive as the input. Let’s cover these three utility types in more detail.

Partial

Partial is used to create a new type with all of the fields set to optional. It’s great for working with an incomplete instance of the object where you don’t know ahead of time which fields may be missing:

1
2
3
4
5
6
7
8
9
type User = {
  id: number;
  name: string;
  address: string;
}

function updateUserObj(id: number, body: Partial<User>) {
  return makeUpdateAPIRequest(); // Updates the user using partial user object as a body;
}

Since we’re setting our body param to type Partial<User>, we don’t have to provide the whole user object to this function, only the fields we’re trying to update.

Readonly

The Readonly utility type is used to create a new type with all of the fields set to read-only. TypeScript won’t allow you to change any field value on such objects after the initialization:

1
2
3
4
5
6
7
8
type User = {
  id: number;
  name: string;
  address: string;
}

const readonlyUser: Readonly<User> = { id: 0, name:"John Doe", address: "private" };
readonlyUser.address = "public"; //ERROR: Cannot assign to 'address' because it is a read-only property.

Here, we’re creating a new type Readonly<User>, with the same fields as the User type, except with read-only permissions. When we create a new instance of the readonlyUser and try modifying it, TypeScript throws an error.

Pick

The Pick utility is used for creating a new type by indicating which fields you wish to copy. To choose the fields, you pass them as a union type:

1
2
3
4
5
6
type PickedUser = Pick<User, "id" | "name">

const pickedUser: PickedUser = {
  id: 1,
  name: "John Doe"
}

See my other post on the most useful utility types for React to learn more about utility types and how they can help you.

Conditional types

Now let’s cover conditional types. We know about conditional expressions in JavaScript:

1
const myLabel = isEven ? "even" : "odd";

Turns out, we can use the same syntax to define types. TypeScript’s conditional types allow us to return a different type based on the value of the input type we receive:

1
2
3
4
5
6
type Cat = { catName: string }
type Dog = { dogName: string }

type BarkOrMeow<T> = T extends Dog ? { barkSound: "Bark!" } : { meowSound: "Meow!" };

type CatSound = BarkOrMeow<Cat>;

The code above defines a conditional type, BarkOrMeow<T>, which returns a type with either barkSound or meowSound depending on the input T. Then we create a CatSound type by passing the Cat type to BarkOrMeow.

Even with this trivial example, you can see how useful TypeScript conditional types can be.

Distributed conditional types

When defining conditional types, instead of returning a single type as part of our conditional statement, we can return several distributed types.

Distributed types allow you to add another level of flexibility to your code and handle more complex edge cases:

1
2
3
4
5
type dateOrNumberOrString<T> = T extends Date ? Date : T extends number ? Date | number : never

function compareValues<T extends Date | number> (value1: T, value2: dateOrNumberOrString<T>) {
  // do the comparison
}

In the example above, we’re using distributed conditional type dateOrNumberOrString to enforce the type for the second parameter of our compareValues function. If the value1 is a Date, we want value2 to also be a Date. If value1 is a number, we want value2 to be either Date or a number.

Conditional type inference

A more complex case for conditional types is inferring the new type as a part of the conditional statement. We can use the infer keyword to infer the new type based on a certain property or signature of the input we receive.

This might sound a bit too abstract without a concrete example, so let’s take a look at one:

1
type inferFromFieldType<T> = T extends { id: infer U } ? U : never;

In this example, we infer type U from the id field of type T. If T has the id property, TypeScript infers the type of that property as U. You can then use U within the same conditional type statement.

Conditional type inference allows creating a robust type checking logic that can deal with deeply nested objects.

Type inference from function signatures

In the same fashion that we infer types from object fields, we can also infer types from function signatures.

We can infer types from function parameters as well as from function return types:

1
2
type inferFromFunctionParam<T> = T extends (a: infer F) => void ? F : never;
type inferFromFunctionReturnType<T> = T extends () => infer F ? F : never;

Here we have two generic types that are inferring types from input function types. The first one uses the function parameters and the other function’s return type.

Let’s take a look at how you would use one of these types in action:

1
2
3
4
5
6
7
8
function executeFunction<T extends (param: any) => void> (fn: T, arg: inferFromFunctionParam<T>) {
  fn(arg);
}

function sampleFunction(param: string) {
  // Do something here!
}
executeFunction(sampleFunction, "Hello world");

We have executeFunction function that uses inferFromFunctionParam generic type we created earlier on its second argument arg.

The first argument of executeFunction is a function that takes one parameter, param. TypeScript will infer the type of the second argument arg from the type of param.

In other words, if fn is supposed to receive a string, TypeScript ensures we can only pass a string as a value for the second arg parameter of executefunction.

If we tried to call executeFunction with a number, TypeScript would throw an error:

1
executeFunction(sampleFunction, 1); //Argument of type 'number' is not assignable to parameter of type 'string'

The ability to enforce types based on the function signature of function parameters you receive allows for a whole other level of type safety.

Conclusion

In this post, we covered advanced type inference and combined it with generics to build flexible types on top of other ones. With a strong grasp on generics and type inference, we can ensure all of the data that flows through our app has strong type safety.

And that concludes our deep dive into TypeScript generics. Again if you haven’t read the first part yet, I encourage you to do so as it lays the foundation for understanding TypeScript generics .

If you’d like to get more web development, React and TypeScript tips consider following me on Twitter, where I share things as I learn them.
Happy Typescripting!

Share on

Software Development Tutorials
WRITTEN BY
Iskander Samatov
The best up-to-date tutorials on React, JavaScript and web development.