Problem
Suppose you are dealing with objects like these:
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:
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:
const identity1 = (arg: Record<string, string[]>) => arg;
In this case, we just get an object of Record<string, string[]
.
In this way. we allow an object in which "foo" and "bar" are exchanged, which we don't like to:
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;
Seems good, huh? But we get this:
This type is essentially the same as the last one:
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;
}
This way typescript can infer the type of an argument very specifically:
And doesn't allow illegal objects:
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;
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;
}
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
Top comments (0)