Type inference in mapped types: a rabbit hole and a technique to jump out
Tags: typescript
Published: 2022-07-05 03:49:26

## Problem

Suppose you are dealing with objects like these:

```typescript
const foobar = {
  foo: ["foo"],
  bar: ["bar", "bar"],
}

const boo = {
  boo: ["boo", "boo", "boo"],
}
```

How would you type them? They seem to be characterized as follows:

- The type of keys is `string`.
- Numbers of keys are unknown.
- Values are arrays of keys.

And as always, we want to impose the strongest restriction and inference on the type of arguments as possible.

---

## Discussion

Let's think with the following `identity` function for the sake of simplicity:

```typescript
const result = identity({
  foo: ["foo"],
  bar: ["bar", "bar"],
});
// => result = {
//      foo: ["foo"],
//      bar: ["bar", "bar"],
//    }
```

Then we can see how typescript infers the type of the argument by checking the type of `result`.

### Case 1: Record

Let's start with a simple `Record` type:

```typescript
const identity1 = (arg: Record<string, string[]>) => arg;
```

In this case, we just get an object of `Record<string, string[]`.

![result1](https://storage.googleapis.com/zenn-user-upload/c45923b4e82d-20220704.png)

In this way. we allow an object in which "foo" and "bar" are exchanged, which we don't like to:

![counter1](https://storage.googleapis.com/zenn-user-upload/527348dda913-20220705.png)

This is because that we have no restriction on the relationship between the type of keys and that of values in `Record` types.

### Case 2: Mapped Type

Next we try a mapped type:

```typescript
const identity2 = (arg: {
　[T in string]: T[],
}) => arg;
```

Seems good, huh? But we get this:

![result2](https://storage.googleapis.com/zenn-user-upload/0c770431a8b4-20220705.png)

This type is essentially the same as the last one:

![counter2](https://storage.googleapis.com/zenn-user-upload/2d8ef5544e05-20220705.png)

We did specify the relationship between the types of keys and values as `[T in string]: T[]`! Where did it go? What should we do instead?

---

## Answer

Here is the solution:

```typescript
function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}
```

This way typescript can infer the type of an argument very specifically:

![answer](https://storage.googleapis.com/zenn-user-upload/9c628ad1beb9-20220705.png)

And doesn't allow illegal objects:

![error](https://storage.googleapis.com/zenn-user-upload/f98c26a01e1e-20220705.png)

---

## Commentary

So what's the difference between `identity2` and `identity3`? Let's recall what mapped types are first:

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html

It says:

> A mapped type is a generic type which uses a **union of PropertyKeys** (frequently created via a keyof) to iterate through keys to create a type:

It means that if we write `[T in X]`, `X` should be an union, or something like `"foo" | "bar" | "boo"`.

Now let's recall how we defined `identity2`:

```typescript
const identity2 = (arg: {
　[T in string]: T[],
}) => arg;
```

We put `string` into `X` and typescript allowed it. That should means that `string` is a kind of union.

But what is `string` as an union? We may consider it as an union of every string literal we could make, or a collection of an unlimited number of strings.

Therefore, `identity2` is defined to map every element `T` in such a collection onto `T[]`, but typescript does not think it possible for us. Then the type falls back to something like an ordinary record.

How about `identity3` then, which is the solution here?

```typescript
function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}
```

It uses `U extends string` instead of `string`. This way `U` may be considered as a subset of `string` which has a limited number of string literals, and the type is regarded as a valid mapped type!

In the example where we pass `{ foo: ["foo"], bar: ["bar"] }` to the function, `"foo" | "bar"` matches with `U` and all of the entries are confirmed to mapped to `T[]` as defined.

---

## Afterword

If you are interested in how the technique is used in real world scenarios, see the source code of my ongoing work, which is a framework for building web APIs with Deno + Cloudflare Workers:

https://github.com/hasundue/flash

Thanks for your kind reading!

---

## References

- The original version in Japanese: https://zenn.dev/articles/a8b36dafffc067