Search code examples
typescripttypescript-typingstypescript-genericsmapped-types

Iteration through mapped type record with generic value


I have two records (record of options and record of instances) that have correspondence between key and value. I guarantee this correspondence by using mapped type with generic value. My task is to create a record of instances based on the record of options. Because common for..in loop drops the type of a key of the object to just string and especially drops corresponding between key and value, I'm using a workaround with a generic callback of Array.forEach. But this also doesn't help to set up types correctly.

Code example that reproduces the problem:

type key = 'key1' | 'key2';
type someOption<T extends key> = `${T}`;
type someInstance<T extends key> = Array<`${T}`>;
type record1 = {
  [T in key]: someOption<T>;
};
type record2 = {
  [T in key]: someInstance<T>;
}

const rec1: record1 = {
  key1: 'key1',
  key2: 'key2',
};
const rec2: Partial<record2> = {};

Object.entries(rec1).forEach(<T extends key>([recKey, recValue]: [T, someOption<T>]) => {
  rec2[recKey] = [recValue, recValue];
});

// drop partial after mapping
// return rec2 as record2;

Type script error in 18th line:

TS2322: Type `${T}`[] is not assignable to type Partial[T]
Type `${T}`[] is not assignable to type
someOtherGenericType<'key1'> & someOtherGenericType<'key2'>
Type `${T}`[] is not assignable to type someOtherGenericType<'key1'>
Type `${T}` is not assignable to type 'key1'
Type key is not assignable to type 'key1'
Type 'key2' is not assignable to type 'key1'

It seems like rec[recKey] has type Partial[T] but after parsing it drops the generic variable T and becomes just Array<`key1`> & Array<`key2`>. I don't understand why there are & not | between types after parsing and why parsing drops T. Or maybe I doing all wrong

I'm using type script 5.0.4 and node 16.19.1 if it is important

My question is: what is the best way to iterate through the mapping of options and create the mapping of records with the guarantee of type and corresponding key/value?


Solution

  • There are two problems with this code. Note that the question asks for the "best" way to fix the problems, but since that is subjective, I'm going to interpret this as asking for a way that stays close to the original design and runtime behavior.


    The first problem is that the TypeScript call signature for the Object.entries() method doesn't strongly type its output the way you expect. This is similar to the behavior for Object.keys(). Objects in JavaScript can always have more properties than TypeScript knows about, so enumerating the properties can possibly give you more things than TypeScript could predict. Hence you get string[] for the keys of an object of type X, and not (keyof X)[]. If you are sure your object has no extra keys, then you can use techniques as discussed in questions like Preserve Type when using Object.entries to use a keyof type. That could look like:

    type Entry<T> = { [K in keyof T]: [K, T[K]] }[keyof T];
    function objectEntries<T extends object>(t: T): Entry<T>[] {
      return Object.entries(t) as any; // you need some assertion here
      // if TS allowed this without "as any" you wouldn't need this function at all
    }
    

    where objectEntries({a: "", b: 1, c: true}) will produce a value of type (["a", string] | ["b", number] | ["c", boolean])[]. It works by mapping over the keys in T and then indexing into it to get the union of key/value pairs.


    The second problem is that inside your callback, the compiler cannot follow the logic that would ensure rec2's type allows a value of type [K, SomeOption<K>] as a property value for the the recKey key. TypeScript doesn't see the correlation between the type of rec2[recKey] and the type of [recValue, recValue]. Instead, it behaves as if recKey might be of the full union type "key1" | "key2", and that recValue might be of the union type SomeOption<"key1"> | SomeOption<"key2">. It's therefore worried that you might assign the key2-appropriate value to the key1 property or vice versa, and you get an error about an intersection type as described in microsoft/TypeScript#30769.

    The recommended approach to dealing with correlated types like this is described in microsoft/TypeScript#47109 and mostly has to do with writing all your types in terms of a base type, and generic indexes into that type or into mapped types over that type. The details are spelled out in that issue. But here's the minimal approach that fixes your issue:

    objectEntries(rec1).forEach(
      <K extends Key>([recKey, recValue]: [K, SomeOption<K>]) => {
        const r2: { [P in K]?: SomeInstance<P> } = rec2;
        r2[recKey] = [recValue, recValue]; // okay
      });
    

    What I've done here is widen the type of rec2 to { [P in K]?: SomeInstance<P> } and saving it to the variable r2. This is seen as a valid widening. Now, when examining r2[recKey], the compiler will immediately recognize this as being of type SomeInstance<K> | undefined, because you're indexing into it with the exact key type it's known to have. Instead of a union of indexed access types, you get a single type. And you can assign an Array<SomeOption<K>> to that type, so it compiles cleanly.


    So that fixes your issues. Again, this might not be the "best" way to do it. Indeed, the refactoring involved in microsoft/TypeScript#47109 can often be more annoying than seems worthwhile (although it's not much work here). If so, you can always just use a type assertion to move on, like

    Object.entries(rec1).forEach(([recKey, recValue]) => {
      (rec2 as any)[recKey] = [recValue, recValue];
    });
    

    No, that isn't guaranteed to be type safe, but if you are already convinced that you wrote it correctly you might prefer to just move past it rather than try to force the compiler to understand.

    Playground link to code