DEV Community

A. Sharif
A. Sharif

Posted on • Updated on

Notes on TypeScript: Mapped Types and Lookup Types

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how leverage TypeScript in a specific situation. All examples are based on TypeScript 3.2.

MappedTypes

In this part of the "Notes on TypeScript" series we want to level up on our type level programming knowledge in TypeScript. To get a better fundamental understanding of the topic, we will revisit some topics we discussed in "Type Level Programming Part 1" and see in more depth how these types are implemented. To be more specific let's gain a better understanding of Mapped Types.

In a previous post we implemented a Partial, a Required and a ReadOnly type.

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

type MakeReadOnly<Type> = {readonly [key in keyof Type ]: Type[key]};
// Test MakeReadOnly
type ReadOnlyUser =  MakeReadOnly<User>;

/*
type ReadOnlyUser = {
  readonly id: number;
  readonly name: string;
}
*/
Enter fullscreen mode Exit fullscreen mode

We implemented these type without discussing in more depth, the actual underlying mechanism, which we will do now. Taking a look at our MakeReadOnly, we notice that we can map over the property types enabling to create a new type. In the specific example above, we mapped over all the property types and transformed them to readonly.

Let's take a look at another example we previously implemented.

type MakePick<Type, Keys extends keyof Type> = { [Key in Keys]: Type[Key] };
Enter fullscreen mode Exit fullscreen mode

Our MakePick, which reflects the provided Pick, goes through all the keys, that extend the provided Type and returns a new type.

type User = {
  id: number;
  name: string;
  points: number;
};
type TestMakePick = MakePick<User, "id" | "name">;

/*
type TestMakePick = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

This is what is actually happening when we try to select the id and name from User:

type TestMakePick = { [Key in "id" | "name"]: User[Key] };

/*
type TestMakePick = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

Using lookup types, which we will explore in more depth in the next section, we can rewrite the above example to the following:

type TestMakePick = {
  id: User["id"],
  name: User["name"]
};

/*
type TestMakePick = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

The above transformations, should help us get a better idea of how mapped types work.

Lookup Types

To build more advanced types, let's look at two interesting TypeScript features Lookup types and keyof.

type UserKeyTypes = User["id" | "name" | "points"];

/*
type UserKeyTypes = number | string;
*/
Enter fullscreen mode Exit fullscreen mode

By using keyof, we can rewrite the above type to the following:

type UserKeyTypes = User[keyof User];

/*
type UserKeyTypes = number | string;
*/
Enter fullscreen mode Exit fullscreen mode

keyof can help us avoid having to manually define all the keys, rather letting TypeScript provide the keys, which helps in avoiding having to keep updating these types once they change.

type UserKeys = keyof User;

/*
type UserKeys = "id" | "name" | "string";
*/
Enter fullscreen mode Exit fullscreen mode

Lookup types, also referred to as indexed access types enable us to access the types for provided keys. Similar to how we can access the values of object properties, but for types.

type UserNameType = User["name"];

/*
type UserNameType = string;
*/
Enter fullscreen mode Exit fullscreen mode

We can also look up a number of keys as seen in the first example we wrote in this section:

type UserKeyTypes = User["id" | "name" | "points"];

/*
type UserKeyTypes = number | string;
*/
Enter fullscreen mode Exit fullscreen mode

This is an interesting feature and enables developers to provide more explicit type handling when dealing with object properties. Let's replicate a simpler (sans currying) implementation of the prop and assoc functions from the Ramda library.

function prop(obj, key) {
  return obj[key]
}
Enter fullscreen mode Exit fullscreen mode

One way to type the prop function would be to provide an object type and define the key as a string. But TypeScript will not be able to infer the return type.

We need to be more explicit about the key type, which we can achieve by guaranteeing that the key type extends the provided object key types via defining: Key extends keyof Type.

function prop<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

const user: User = {
  id: 1,
  name: "Test User",
  points: 0
};

const userName = prop(user, "name"); // const userName : string;
Enter fullscreen mode Exit fullscreen mode

The interesting aspect here, is that TypeScript can now infer the return type of the return value:

return obj[key]; // => Type[Key]
Enter fullscreen mode Exit fullscreen mode

Another benefit we get by leveraging lookup types and keyof is that we can ensure that only existing property keys can be passed to prop.

// The following will result in TypeScript complaining:
const userName = prop(user, "status");
// Argument of type '"status"' is not assignable to parameter of type '"id" | "name" | "points"'
Enter fullscreen mode Exit fullscreen mode

So trying to access an non existent property will cause TypeScript to complain that the provided type is not assignable. To gain more understanding and validate what we have learned so far, let's implement assoc.

function assoc<Type, Key extends keyof Type>(
  obj: Type,
  key: Key,
  value: Type[Key]
) {
  return { ...obj, [key]: value };
}
Enter fullscreen mode Exit fullscreen mode

assoc looks similar to the prop function, but as we are also providing a value this time, we can use lookup types to define the expected value type via Type[Key].

const updatedUserName = assoc(user, "name", "User Test A");
const updatedUserPoints = assoc(user, "points", 0);

// The following will examples result in TypeScript complaining:

const updatedUserPoint = assoc(user, "point", 0);
// Argument of type '"point"' is not assignable to parameter of type '"id" | "name" | "points"'

const updatedUserPointsAsString = assoc(user, "point", "0");
// Argument of type '"0"' is not assignable to parameter of type 'number'
Enter fullscreen mode Exit fullscreen mode

Via leveraging lookup types we can guarantee that the expected update value has the correct type.

Mapped Types and Lookup Types

Finally, let's revisit a more advanced example we built when working with conditional types.

type RemoveUndefinable<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
}[keyof Type];

type RemoveNullableProperties<Type> = {
  [Key in RemoveUndefinable<Type>]: Type[Key]
};

type TestRemoveNullableProperties = RemoveNullableProperties<{
  id: number;
  name: string;
  property?: string;
}>;

/*
type TestRemoveNullableProperties = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

The RemoveNullableProperties conditional type expects a type and returns a new type only containing non nullable property types. It's a good idea to break the existing implementation into multiple parts as we have a better understanding of mapped and lookup types now.

type RemoveUndefinable<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
}[keyof Type];
Enter fullscreen mode Exit fullscreen mode

Let's start with RemoveUndefinable first:

type RemoveUndefinableKeys<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
};

type TestRemoveUndefinableKeys = RemoveUndefinable<{
  id: number;
  name: string;
  property?: string;
}>;

/*
type TestRemoveUndefinableKeys = {
  id: "id";
  name: "name";
  property?: undefined;
}
*/
Enter fullscreen mode Exit fullscreen mode

We get a new type containing the key names or undefined depending wether the type extends undefined or not. If you recall, we can lookup multiple type like so: {a: number; b: string; c: number[]}["a" | "b"], which means we can use a lookup to extract all the relevant keys for the provided types in the next step.

type RemoveUndefinableKeys<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
};

type RemoveUndefinable<Type> = RemoveUndefinableKeys<Type>[keyof Type];

type TestRemoveUndefinable = RemoveUndefinable<{
  id: number;
  name: string;
  property?: string;
}>;

/*
type TestRemoveUndefinable = "id" | "name" | undefined;
*/
Enter fullscreen mode Exit fullscreen mode

RemoveUndefinable returns all the mapped key names, so our next step is remove any non existent keys from the provided type.

type RemoveUndefinableKeys<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
};

type RemoveUndefinable<Type> = RemoveUndefinableKeys<Type>[keyof Type]>;

type RemoveNullableProperties<Type> = {
  [Key in RemoveUndefinable<Type>]: Type[Key]
};

type TestRemoveNullableProperties = RemoveNullableProperties<{
  id: number;
  name: string;
  property?: string;
}>;

/*
type TestRemoveNullableProperties = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

We can exchange the RemoveNullableProperties with the TypeScript provided Pick type, as the implementation of Pick is similar to the RemoveNullableProperties implementation:

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
}
Enter fullscreen mode Exit fullscreen mode

Exchanging RemoveNullableProperties with Pick leaves us with the following implementation:

type RemoveUndefinableKeys<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
};

type RemoveNullableProperties<Type> = Pick<
  Type,
  RemoveUndefinableKeys<Type>[keyof Type]
>;

type TestRemoveNullableProperties = RemoveNullableProperties<{
  id: number;
  name: string;
  property?: string;
}>;

/*
type TestRemoveNullableProperties = {
  id: number;
  name: string;
};
*/
Enter fullscreen mode Exit fullscreen mode

We should have a good understanding of mapped types, lookup types and keyof and how to leverage them when working with TypeScript. The knowledge gained in this write-up should help us to build more advanced type level programming examples in the upcoming "Notes on TypeScript", which will be focusing on more advanced type level programming.

Links

TypeScript 2.1 Release notes

TypeScript 2.1: Mapped Types

TypeScript 2.1: keyof and Lookup Types

Notes on TypeScript: Type Level Programming Part 1

Notes on TypeScript: Conditional Types

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Top comments (1)

Collapse
 
kapral18 profile image
Karen Grigoryan • Edited

Incredible series, thanks so much for sharing this.

Would greatly appreciate if you could clarify the following:

In this example

type RemoveUndefinableKeys<Type> = {
  [Key in keyof Type]: undefined extends Type[Key] ? never : Key
};

when we pass this portion of a Type

property?: string;

which is the same as

property?: string | undefined

it evaluates to truthy branch of extends, and
I don't quite understand how does it form undefined?

type TestRemoveNullableProperties = {
  ...
  property?: undefined;
}

when supposedly, it should be getting never in true branch.


Another even more confusing thing is if we explicitly
pass

property: string | undefined;

skipping ? marking operator,

we will get

type TestRemoveNullableProperties = {
  ...
  property: never;
}

Thank you.