Indexed Access Types (Lookup Types) in TypeScript

16 Nov, 2020
  • Share
Post image

Indexed Access Types

In TypeScript you can reuse the type of a property of another type.

interface User {
  id: number;
  name: string;
  address: {
    street: string;
    city: string;
    country: string;
  };
}

In the code above we can reuse the types of the User interface's id and address properties.

Let's say, I need to create a function for updating the address of a user:

function updateAddress(
  id: User['id'],
  address: User['address']
) {}

Instead of using a number to describe the id parameter I described it using the User['id'] type which refers to the type of the id property from the User interface. This type is called index access type or lookup type. And for the address parameter I used the type of the address property.

We can access the types of nested properties as well:

type City = User['address']['city']; // string

And we can get the types of multiple properties at once:

type IdOrName = User['id' | 'name']; // string | number

Of course, I could split the User interface into multiple types and reuse those types instead of using the lookup types:

type UserId = number;

interface UserAddress {
  street: string;
  city: string;
  country: string;
}

interface User {
  id: UserId;
  name: string;
  address: UserAddress;
}

function updateAddress(id: UserId, address: UserAddress) {}

Splitting a large type into multiple types looks fine, as long as these smaller types are going to be reused frequently. There are cases when we need to use a part of a type just once and it doesn't make much sense to move that part into a separate type.

Also, the lookup type is useful when we need to reuse a part of some type that we cannot touch, like, for example, a type from a third-party library.

The keyof Operator

The keyof operator is used to query the names of the properties of a type and represent them as a union (key = property name):

interface User {
  id: number;
  name: string;
}

type UserProperties = keyof User; // "id" | "name"

So, the UserProperties type is a union of properties that are present in the User interface.

Also, the type keyof T is a subtype of string:

let userProperty: UserProperties = 'id';
let someString: string = userProperty; // OK

Assigning keyof T to a string works, but, assigning any string to keyof T doesn't:

userProperty = someString; // Error

Lookup Types + keyof Operator + Generics

Describing Access to Any Property in a Given Object

We can use the lookup type together with the keyof operator, for example, in order to describe a function that reads the value of a property from an object:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

TypeScript infers the return type of this function to be T[K] and when we will call this function TypeScript will infer the actual type of the property that we're going to read:

let user = { name: 'John Doe', age: 25 };
let name = getProperty(user, 'name'); // string
let age = getProperty(user, 'age'); // number

The name variable is inferred to be a string and age - a number.

Also, TypeScript will produce an error if you try to assign anything other than a "name" or "age" to the key parameter in the getProperty function:

let age = getProperty(user, 'nonexistentProperty'); // Error

Inferring the Type of a Parameter Based on Another Parameter in a Function

A similar pattern is used to describe document.addEventListener in the DOM library included with TypeScript (lib.dom.d.ts):

// I shortened the original declaration
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (..., ev: DocumentEventMap[K]) => any, ...): void;

This pattern allows TypeScript to infer the type of the event object ev that is passed to the listener callback, based on the type of the event - K. For example, for the event type "click", the event object in the callback should be of type MouseEvent:

document.addEventListener('click', (e) => {
  // e is inferred to be MouseEvent
});

document.addEventListener('keypress', (e) => {
  // e is inferred to be KeyboardEvent
});

This pattern looks useful, so I recreated a simple example:

interface MyMouseEvent {
  x: number;
  y: number;
}

interface MyKeyboardEvent {
  key: string;
}

interface MyEventObjects {
  click: MyMouseEvent;
  keypress: MyKeyboardEvent;
}

function handleEvent<K extends keyof MyEventObjects>(
  eventName: K,
  callback: (e: MyEventObjects[K]) => void
) {
  // ...
}

handleEvent('click', (e) => {
  // e is inferred to be MyMouseEvent
});

I created two types to describe two different event objects: MyMouseEvent and MyKeyboardEvent. Then, I created MyEventObjects type to map event names to the corresponding event objects. And I created a generic function called handleEvent, that allows to register a callback for a specific event.

Then I tried to implement the handleEvent function:

function handleEvent<K extends keyof MyEventObjects>(
  eventName: K,
  callback: (e: MyEventObjects[K]) => void
) {
  if (eventName === 'click') {
    // Here, I expected e to be MyMouseEvent
    callback({ x: 0, y: 0 }); // ERROR
  } else if (eventName === 'keypress') {
    // Here, I expected e to be MyKeyboardEvent
    callback({ key: 'Enter' }); // ERROR
  }
}

Basically, I tried to narrow the parameter of the callback to a more specific type.

When I checked whether the event name is "click", I expected TypeScript to infer the parameter of the callback to be MyMouseEvent, because TypeScript infers the type of this parameter correctly when the handleEvent function is called (check the earlier example).

Basically, inside of the "click" if block I told TypeScript that generic type parameter K is equal to "click" and expected TypeScript to substitute "click" for the parameter K in the declaration of the callback: callback: (e: MyEventObjects["click"]) => void. But, this didn't happen, because TypeScript didn't recognise the relationship between eventName: K and callback: (e: MyEventObjects[K]) => void.

Then, I figured out that TypeScript infers the type of the callback's parameter e to be an intersection(&) of MyMouseEvent and MyKeyboardEvent:

e: MyEventObjects[K]  >>>>  e: MyMouseEvent & MyKeyboardEvent

And it doesn't narrow this type down to a more specialised type after the parameter K becomes known inside of the function.

So, to fix the errors we'd have to use an assertion:

if (eventName === 'click') {
  callback({ x: 0, y: 0 } as MyEventObjects[K]);
} else if (eventName === 'keypress') {
  callback({ key: 'Enter' } as MyEventObjects[K]);
}

And that's how it works, at least at the moment.

Finally, let's have a look at the following code:

type JustObjects = MyEventObjects[keyof MyEventObjects]; // MyMouseEvent | MyKeyboardEvent

Here, JustObjects is a union of the types of the values of MyEventObjects interface.

Pssst...

You know React already? Wanna learn how to use TypeScript with it?

Get this detailed online course and learn TypeScript and how to use it in React applications.

Course thumb image