0

I have the following type:

type mapOptions = {
   'a': {},
   'b': {
      'somethingElse': string,
      'somethingDiff': number
   },
   'c': {
      'somethingC': string
   }
}

Now I would like to create a Map, that can have it's keys set to the keys of my object, and take up for the value a specific object, one like this:

type innerMapObject<T extends keyof mapOptions> {
     status: boolean,
     options: mapOptions[T]
}

Basically, what I want, is that after I have my map, when fetching or setting values in it, to get the correct underlying option types:

const MyMap: Map<...> = new Map();

MyMap.set('d', {}); // error here, "d" is not a property of "mapOptions";
MyMap.set('a', {}); // works correctly
MyMap.set('c', {
      "somethingD": "test"
}); // error here, the value object does not match "mapOptions["c"]"

/** This should be of type:
 *
 *  {
 *      status: boolean,
 *      options: {
 *         somethingElse: string,
 *         somethingDiff: number
 *      }
 *  }
 *
 *
 */
const myBValue = MyMap.get("b");

Is it possible to somehow back-reference the key of the map inside the value associated with that key?

Adam Baranyai
  • 3,635
  • 3
  • 29
  • 68

1 Answers1

1

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 Maps 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

sno2
  • 3,274
  • 11
  • 37
  • This is a really nice solution! However, I would like to understand it also, how is the iteration happening over the array here? When does typescript iterate over my original object, and how? – Adam Baranyai Apr 01 '22 at 05:40
  • @AdamBaranyai I've edited my answer to include an explanation. – sno2 Apr 01 '22 at 15:10