DEV Community

Shun Ueda
Shun Ueda

Posted on

Type inference in mapped types: a rabbit hole and a technique to jump out

Problem

Suppose you are dealing with objects like these:

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

const boo = {
  boo: ["boo", "boo", "boo"],
}
Enter fullscreen mode Exit fullscreen mode

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:

const result = identity({
  foo: ["foo"],
  bar: ["bar", "bar"],
});
// => result = {
//      foo: ["foo"],
//      bar: ["bar", "bar"],
//    }
Enter fullscreen mode Exit fullscreen mode

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:

const identity1 = (arg: Record<string, string[]>) => arg;
Enter fullscreen mode Exit fullscreen mode

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

result1

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

counter1

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:

const identity2 = (arg: {
 [T in string]: T[],
}) => arg;
Enter fullscreen mode Exit fullscreen mode

Seems good, huh? But we get this:

result2

This type is essentially the same as the last one:

counter2

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:

function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}
Enter fullscreen mode Exit fullscreen mode

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

answer

And doesn't allow illegal objects:

error


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:

const identity2 = (arg: {
 [T in string]: T[],
}) => arg;
Enter fullscreen mode Exit fullscreen mode

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?

function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)