Search code examples
typescriptkey

Mapping an array of objects to dictionary in typescript, id as keys


Given some types and data like the following, how can I hint the types in a way that I get autocompletion for the keys, i.e. dictionary.Germany?

type Entry = {
  tld: string;
  name: string;
  population: number;
};

const data: Entry[] = [
  {tld: 'de', name: 'Germany', population: 83623528},
  {tld: 'at', name: 'Austria', population: 8975552},
  {tld: 'ch', name: 'Switzerland', population: 8616571}
];

let dictionary = Object.fromEntries(data.map(item => [item.name, item]));

(Now dictionary is of type { [key: string]: Entry }, e.g. { Germany: {tld: 'de', …}, …}.)


To be clear, my objectives are:

  1. The end result is to have the data in both a list of Entry objects…
  2. …and an object map with the Entry objects.
  3. I need to write the name of an object only once, either as name or key.
  4. The dictionary needs to know its keys in an IDE like WebStorm.
  5. dictionary.Germany === data[0]

Solution

  • There are a number of problems preventing your code from working as-is.


    First, you annotated the type of data as Entry[], thereby telling the compiler to throw away any more specific information about the initializing array literal. Entry only knows that name is string. But you care about the string literal types of the name properties.

    Instead of annotating, you should probably use a const assertion to tell the compiler that you want it to keep track of all the literal types of the initializer (this is overkill if all you care about is name but it doesn't necessarily hurt anything). Then, if you want to be sure that it matches Entry[], you can use the satisfies operator:

    const data = [
      { tld: 'de', name: 'Germany', population: 83623528 },
      { tld: 'at', name: 'Austria', population: 8975552 },
      { tld: 'ch', name: 'Switzerland', population: 8616571 }
    ] as const satisfies Entry[];
    

    (The above compiles only with TypeScript 5.3 or above due to microsoft/TypeScript#55229; in TypeScript 4.9 to 5.2 you'd need to use readonly Entry[] instead.)


    Now TS knows about "Germany", "Austria", and "Switzerland", but it doesn't have any idea that the Object.fromEntries() method produces a value with those keys. The current type definition looks like

    interface ObjectConstructor {
      fromEntries<T = any>(e: Iterable<readonly [PropertyKey, T]>): { [k: string]: T; };
    }
    

    meaning that its output always has a string index signature. It's not impossible to write a more specific call signature, as was suggested in microsoft/TypeScript#35745, but it's tricky to get it right and wasn't seen as worth the complexity.

    If you want to write such a signature yourself you can do so in your own code base and merge it in like this:

    // declare global {
    interface ObjectConstructor {
      fromEntries<E extends readonly [PropertyKey, any][]>(
        entries: E
      ): { [T in E[number] as T[0]]: T[1] };
    }
    // }
    

    That iterates over the elements of entries and uses key remapping to produce an object type.


    After this you're pretty much done. Still, the resulting type of dictionary will have this awful union of const-asserted junk as its property value type, when all you really want is Entry. So we can use item satifies Entry as Entry to safely widen item to Entry (writing item satisfies Entry would check it but not widen it, and writing item as Entry would widen it but not check it).

    So, finally, that gives us:

    let dictionary =
      Object.fromEntries(data.map(item => [item.name, item satisfies Entry as Entry]));
    /* let dictionary: {
        Germany: Entry;
        Austria: Entry;
        Switzerland: Entry;
    } */
    

    Now you have exactly the behavior you wanted, more or less. Is it worth it? I suppose that depends on your use cases.

    Playground link to code