'Defining object type after a reduce function based on generic key types

I have this helper function I'm using to transform an array of objects with many keys into a dictionary with key value pair where the key is the value of one of the keys and the value is the value of another key on the same array element.

 export const getMappingDictionary = <T extends { [key: string]: any }>(objectsToMap: T[], key: keyof T, value: keyof T) => {
  return objectsToMap.reduce((prev, cur) => ({ ...prev, [cur[key]]: cur[value] }), {}); 
};

So an array like:

[ {name: "chair", id: 1, description: "omg a chair"},
 {name: "table", id: 2, description: "thats a nice table"},
 {name: "lamp", id: 3, description: "so bright"}]

will become

const mymap = getMappingDictionary(myarray, "name", "id") = 
{ chair: 1, table: 2, lamp: 3 }

However after I've tried playing around with the typing I cannot get it correctly.

Maybe I'm overdoing stuff and it has even a nicer solution.

The motivation behind this method is that I can build enums on the fly by either using static objects in my code (and then usage is mymap.chair, and my IDE can even provide me hints when typing mymap dot) or dinamically by plugging it into an async function that retrieves some objects from a remote API (using it like mymap["chair"] knowing that the chair key is always going to be present but id, or other properties I want to map may change)



Solution 1:[1]

I wouldn't bother with the reduce unless there's a good reason why you can't use Object.fromEntries:

const v: Record<string, number> = 
    Object.fromEntries(data.map(({ name, id }) => [name, id]))

A reusable, generalized version of this (using selector functions, not strings) might be:

const createLookupObject = <T, V>(
    items: T[],
    keySelector: (x: T) => string,
    valueSelector: (x: T) => V
): Record<string, V> => {
    const contentEntryLookup: Record<string, V> = Object.fromEntries(
        items.map((ce) => [keySelector(ce), valueSelector(ce)])
    );
    return contentEntryLookup;
};

so here you would:

createObjectLookup(data, x => x.name, x => x.id);

For posterity, the reduce version of the above looks like this:

const createLookupObject_Reduce = <T, V>(
    items: T[],
    keySelector: (x: T) => string,
    valueSelector: (x: T) => V
): Record<string, V> => {
    const contentEntryLookup: Record<string, V> = items.reduce(
        (acc, curr) => ({ ...acc, [keySelector(curr)]: valueSelector(curr) }), {})
    return contentEntryLookup;
};

Playground link

Solution 2:[2]

Just learning some more advanced TS and found both the question and the answers very interesting. So I challenged myself to create a more generic version which:

  1. allows mapping over string\number\symbol values
  2. allows mapping over values constrained with union type (see last example)
  3. by default maps to a source object (but allows also extracting any given property or create anything you want using source object)

Here it is:

const createLookupObject = <
        T, 
        TKey extends keyof T, 
        TKeyValue extends T[TKey] & (string | number | symbol), 
        TValueExtractor extends (i: T) => unknown = (i: T) => T
    >
(
    items: T[],
    key: TKey,
    valueExtractor?: TValueExtractor
): [TKeyValue] extends [never] ? never : Record<TKeyValue, ReturnType<TValueExtractor>>  => {
    const contentEntryLookup = Object.fromEntries(
        items.map((ce) => [ce[key], valueExtractor ? valueExtractor(ce) : ce])
    );
    return contentEntryLookup;
};

/* EXAMPLES */

const permissions = [
    { "Id": "ACCESS", "Order": 1, "Granted": true },
    { "Id": "EXPORT", "Order": 2, "Granted": true },
    { "Id": "DELETE", "Order": 3, "Granted": true }, 
]

// Record<string, number>
const lookup = createLookupObject(permissions, "Id", i => i.Order)

/*
 Record<number, {
    Id: string;
    Order: number;
    Granted: boolean;
}>;
*/
const lookup2 = createLookupObject(permissions, "Order")

/*
 never - boolean cannot be used as indexer
*/
const lookup3 = createLookupObject(permissions, "Granted")

/*
  Lookup by a key defined with union type
*/
export type LocaleId = 'sk' | 'cs' | 'en'

export interface LocaleInfoRecord {
  isoName: LocaleId,
  nativeName: string,
  isLoaded: boolean,
  localeData?: {},
}
const locales : LocaleInfoRecord[] = [
  { isoName: 'sk', nativeName: 'Sloven?ina', isLoaded: false },
  { isoName: 'cs', nativeName: '?eština', isLoaded: false },
  { isoName: 'en', nativeName: 'English', isLoaded: false },
]

// Record<'sk' | 'cs' | 'en', LocaleInfoRecord>
const localesLookup = createLookupObject(locales, 'isoName')

Playground

Solution 3:[3]

Here you have an example of possible solution:


type Elem = Record<string, string | number>;

export const getMappingDictionary =
    <T extends Elem, Key1 extends keyof T, Key2 extends Exclude<keyof T, Key1>>(objectsToMap: T[], key: Key1, value: Key2) =>
        objectsToMap.reduce((acc, elem) => ({ ...acc, [elem[key]]: elem[value] }), {} as Record<T[Key1], T[Key2]>)

const arr = [
    { name: "chair", id: 1, description: "omg a chair" },
    { name: "table", id: 2, description: "thats a nice table" },
    { name: "lamp", id: 3, description: "so bright" },
];

const mymap = getMappingDictionary(arr, "name", "name"); // error
const mymap1 = getMappingDictionary(arr, "name", "id"); // Record<string, number>
const mymapw = getMappingDictionary(arr, "id", "name"); // Record<number, string>

I assume, that second key can't be equal to first Playground

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3