Indexed Access Types (Lookup Types) in TypeScript
Tags: typescript
Published: 2020-11-16 07:56:35


## Indexed Access Types

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

```ts
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:

```ts
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:

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

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

```ts
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:

```ts
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):

```ts
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`:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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`:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

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

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