Learn how to create a typescript function that converts arrays into type-safe dictionaries and master conditional types with type inference.
Have you ever needed to quickly convert an array into a lookup dictionary? While TypeScript provides the basic tools, you'll often find yourself writing repetitive mapping code. Here's the standard approach:
interface Person { key: number, name: string, birthday: Date }
const data: Person[] = [
{ key: 1, name: 'Rupert', birthday: new Date('1986-04-10') },
{ key: 2, name: 'Anna', birthday: new Date('1989-10-10') },
];
// Create a map using the out of the box Map and Array.map
const dict = new Map(data.map(x => [x.key, x]))
// ^ const dict: Map<number, Person>
While this works, you'll quickly notice it feels verbose and repetitive. What if you could write more intuitive code that's both flexible and type-safe? The toMap
function below gives you exactly that - supporting both property names and custom functions:
// Create a map using the 'key' property
const map1 = toMap(data, 'key');
// ^ const map1: Map<number, Person>
// Create a map using the 'key' function
const map2 = toMap(data, x => x.key);
// ^ const map2: Map<number, Person>
// Create a map using the 'key' property and 'value' property
const map3 = toMap(data, 'key', 'name');
// ^ const map3: Map<number, string>
// Create a map using the 'key' function and 'value' function
const map4 = toMap(data, x => x.key, x => x.name);
// ^ const map4: Map<number, string>
// Create a map using custom functions
const map5 = toMap(data, x => x.birthday, x => `Name: ${x.name}`);
// ^ const map5: Map<Date, string>
// Create a map using no parameters
const map6 = toMap(data);
// ^ const map6: Map<Person, Person>
Ready to build this powerful utility? Follow along as we construct it step by step. Each iteration will teach you new TypeScript concepts that you can apply to your own projects: (click on the edges of the code block to step forward or backward through the versions)
// Start simple - you'll build complexity gradually
export function toMap<T, K>(
array: readonly T[],
keyFn: ((item: T) => K),
) {
if (!array?.length) return new Map<K, T>();
return new Map<K, T>(array?.map(item => [keyFn(item), item]));
}
const dict = toMap(data, x => x.birthday);
// ^ const dict: Map<Date, Person>
// We add some spacing to go through the steps without lines jumping around
export function toMap<
T,
K
>(
array: readonly T[],
keyFn: ((item: T) => K),
) {
if (!array?.length) return new Map<K, T>();
return new Map<K, T>(array?.map(item => [keyFn(item), item]));
}
const dict = toMap(data, x => x.birthday);
// ^ const dict: Map<Date, Person>
// Now you'll learn conditional types - expand the key parameter to accept property names// and move the key type to generics so you can reference it laterexport function toMap<
T,
K,
KF extends keyof T | ((item: T) => K) >(
array: readonly T[],
key: KF,
) {
// - Create your first conditional type - it chooses between property type or function return type type TKeyType = KF extends keyof T ? T[KF] : KF extends ((item: T) => K) ? K : never;
if (!array?.length) return new Map<TKeyType, T>();
// - You'll hit a TypeScript error here - Type 'K' is not assignable to type 'TKeyType' const keyFn: (item: T) => TKeyType = typeof key === 'function'
? key
: typeof array[0] === 'object' && array[0] !== null
? item => item[key as keyof T]
: item => item; // Fallback when the array contains primitive values
return new Map<TKeyType, T>(array?.map(item => [keyFn(item), item]));
}
// - Not the type safety you want yetconst dict = toMap(data, x => x.birthday); // ^ const dict: Map<unknown, Person>
// Here's how you solve the error - use type inference instead// - Remove the K type parameter and let TypeScript infer itexport function toMap<
T,
KF extends keyof T | ((item: T) => any) >(
array: readonly T[],
key: KF,
) {
// - Let TypeScript infer the return type for you type TKeyType = KF extends keyof T
? T[KF]
: KF extends ((item: T) => infer K) ? K
: never;
if (!array?.length) return new Map<TKeyType, T>();
const keyFn: (item: T) => TKeyType
= typeof key === 'function'
? key
: typeof array[0] === 'object' && array[0] !== null
? item => item[key as keyof T]
: item => item;
return new Map<TKeyType, T>(array?.map(item => [keyFn(item), item]));
}
// - Perfect! Now you get proper type inferenceconst dict = toMap(data, x => x.birthday); // ^ const dict: Map<Date, Person>
// Make the function more flexible - optional parameters allow you to do more
export function toMap<
T,
KF extends keyof T | ((item: T) => any) | undefined, >(
array: readonly T[],
key?: KF,
) {
// - Handle the optional case in your conditional types, by returning T type TKeyType = undefined extends KF ? T : KF extends keyof T
? T[KF]
: KF extends ((item: T) => infer K)
? K
: never;
if (!array?.length)
return new Map<TKeyType, T>();
const keyFn: (item: T) => TKeyType
= typeof key === 'function'
? key
: key !== undefined && typeof array[0] === 'object' && array[0] !== null ? item => item[key as keyof T]
: item => item;
return new Map<TKeyType, T>(array?.map(item => [keyFn(item), item]));
}
const dict = toMap(data);
// ^ const dict: Map<Person, Person>
// Apply what you've learned - add value parameter using the same pattern
export function toMap<
T,
KF extends keyof T | ((item: T) => any) | undefined,
KV extends keyof T | ((item: T) => any) | undefined, >(
array: readonly T[],
key?: KF,
value?: KV, ) {
type TKeyType = undefined extends KF ? T : KF extends keyof T ? T[KF] : KF extends ((item: T) => infer K) ? K : never;
type TValueType = undefined extends KV ? T : KV extends ((item: T) => infer VF) ? VF : KV extends keyof T ? T[KV] : never;
if (!array?.length)
return new Map<TKeyType, TValueType>();
const keyFn: (item: T) => TKeyType
= typeof key === 'function'
? key
: key !== undefined && typeof array[0] === 'object' && array[0] !== null
? item => item[key as keyof T]
: item => item;
const valueFn: (item: T) => TValueType = typeof value === 'function' ? value : value !== undefined && typeof array[0] === 'object' && array[0] !== null ? item => item[value as keyof T] : item => item;
return new Map<TKeyType, TValueType>(array?.map(item => [keyFn(item), valueFn(item)]));
}
const dict = toMap(data, 'key', 'birthday');
// ^ const dict: Map<number, Date>
// Your final result - a powerful, type-safe utility you can use anywhere
/**
* Helps to create a map (dictionary) from a list of items.
* For the key and values you can either provide a keyof T or a function that returns the value.
* If you don't specify any value, the whole item will be used as value.
* @param array the array to convert
* @param key a keyof an array item | a function that returns a value that represents the key of the dictionary
* @param value a keyof an array item | a function that returns any value
* @returns an new Map()
*/
export function toMap<
T,
KF extends keyof T | ((item: T) => any) | undefined,
KV extends keyof T | ((item: T) => any) | undefined,
>(
array: readonly T[],
key?: KF,
value?: KV,
) {
type TKeyType = undefined extends KF ? T : KF extends keyof T ? T[KF] : KF extends ((item: T) => infer K) ? K : never;
type TValueType = undefined extends KV ? T : KV extends ((item: T) => infer VF) ? VF : KV extends keyof T ? T[KV] : never;
if (!array?.length)
return new Map<TKeyType, TValueType>();
const keyFn: (item: T) => TKeyType
= typeof key === 'function'
? key
: key !== undefined && typeof array[0] === 'object' && array[0] !== null
? item => item[key as keyof T]
: item => item;
const valueFn: (item: T) => TValueType
= typeof value === 'function'
? value
: value !== undefined && typeof array[0] === 'object' && array[0] !== null
? item => item[value as keyof T]
: item => item;
// Create the map and loop the array only once
const map = new Map<TKeyType, TValueType>();
array?.forEach((item) => {
map.set(keyFn(item), valueFn(item));
});
return map;
}
const dict = toMap(data, 'key', x => x.birthday);
// ^ const dict: Map<number, Date>
Want to see how thoroughly this function is tested? Check out the full test suite here toMap.tests.ts.
What You've Accomplished
You've transformed a simple one-liner into a powerful, reusable utility! While your toMap
function is more complex than new Map(data.map(x => [x.key, x]))
, you've gained:
- Better readability - Your intent is crystal clear when you write
toMap(users, 'id')
- Type safety - Full TypeScript inference protects you from runtime errors
- Flexibility - Support for both property names and custom functions
- Reusability - One function handles all your array-to-dictionary needs
You've also learned some advanced TypeScript concepts that you can apply to other projects: conditional types, type inference, and building flexible APIs. These skills will make you a more effective TypeScript developer.