Yes, although the code may look a little weird. Basically, the Map
type takes in two generics: K
for the key type and V
for the value type. This is similar to Record<K, V>
. However, due to the layout, there is no way to say that certain props from an object would have certain values because all of the keys have the same value type.
There is a workaround, though, because you can create an intersection of the Map
s to make TypeScript infer the overload signatures to allow specific keys to have specific values.
type MyMap = Map<"planet", { name: string; size: number }> & Map<"person", { name: string; age: number }>;
const myMap: MyMap = new Map();
// these work
myMap.set("planet", {
name: "Mars",
size: 21,
});
myMap.set("person", {
name: "Jon Doe",
age: 21,
});
// these fail
myMap.set("invalid_key", 2);
myMap.set("person", {
name: "Hey",
size: 2, // notice I'm setting `size` but it should have `age`
});
TypeScript Playground Link
Although, this code may be quite a handful to write by hand if you have multiple properties. Therefore, I've created a helper type that should make it way easier as it generates a Map<K, V>
from an object type. It takes in the keys and values in an array type of a tuple of [key, value]
. The reason I used an array type is because if you used regular objects then the key type would be limited to PropertyKey
(string | symbol | number
) but Map
allows all sorts of values as the key.
// https://stackoverflow.com/a/50375286/10873797
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type CreateMap<T extends readonly (readonly [unknown, unknown])[]> =
UnionToIntersection<
T[number] extends infer $Value // make distributive
? $Value extends readonly unknown[]
? Map<$Value[0], $Value[1]>
: never
: never
>;
type MyMap = CreateMap<[
["planet", { name: string; size: number }],
["person", { name: string; age: number }],
]>;
const myMap: MyMap = new Map();
// these work
myMap.set("planet", {
name: "Mars",
size: 21,
});
myMap.set("person", {
name: "Jon Doe",
age: 21,
});
// these fail
myMap.set("invalid_key", 2);
myMap.set("person", {
name: "Hey",
size: 2, // notice I'm setting `size` but it should have `age`
});
TypeScript Playground Link
Edit: Explanation
My solution makes use of distributive types. In the CreateMap
type, it takes in a tuple of the keys and values like this:
type CreateMap<Init extends readonly (readonly [unknown, unknown])[]>
= Init[number];
// ["hey", "you"] | ["me", "too"]
type Foo = CreateMap<[
["hey", "you"],
["me", "too"]
]>;
However, if you try to simply plug in these values into Map<K, V>
, then you lose context on the specifics for what key is equal to what value:
type CreateMap<Init extends readonly (readonly [unknown, unknown])[]>
= Map<Init[number][0], Init[number][1]>;
// Map<"hey" | "me", "you" | "too">
type Foo = CreateMap<[
["hey", "you"],
["me", "too"]
]>;
Therefore, we would need a way to enumerate through the items in the tuple as a top-level union. Then, create a Map
for each item and key pair explicitly. This diagram might be a good way to explain it:
// current way
[["hey", "you"], ["me", "too"]] => Map<"hey" | "you", "me" | "too">
// what we want
[["hey", "you"], ["me", "too"]] => Map<"hey", "me"> | Map<"me", "too">
This can be used by making our conditional types distributive on the Init[number]
which will run the conditional for each item in the Init
tuple type. I will do this by simply creating a type alias for the value using infer _
. However, when you infer, you lose context on the types, so we must add another check to assert the inferred type is a tuple to get the key and value types:
type CreateMap<Init extends readonly (readonly [unknown, unknown])[]> =
Init[number] extends infer $Entry
? $Entry extends readonly [infer $K, infer $V]
? Map<$K, $V>
: never
: never;
// Map<"hey", "you"> | Map<"me", "too">
type Foo = CreateMap<[
["hey", "you"],
["me", "too"]
]>;
However, as you can notice, our type is a union of the maps. We need it to be an intersection so that we know the map will have all of the key/value patterns specified at once. We can do this by using @jcalz's UnionToIntersection
type.
// https://stackoverflow.com/a/50375286/10873797
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type CreateMap<Init extends readonly (readonly [unknown, unknown])[]> =
UnionToIntersection<
Init[number] extends infer $Entry
? $Entry extends readonly [infer $K, infer $V]
? Map<$K, $V>
: never
: never
>;
// Map<"hey", "you"> & Map<"me", "too">
type Foo = CreateMap<[
["hey", "you"],
["me", "too"]
]>;
TypeScript Playground Link