Skip to content

Instantly share code, notes, and snippets.

@mikahimself
Last active March 23, 2022 06:24
Show Gist options
  • Save mikahimself/1135c21b6c088120d033ba4485cb2ca7 to your computer and use it in GitHub Desktop.
Save mikahimself/1135c21b6c088120d033ba4485cb2ca7 to your computer and use it in GitHub Desktop.
Working with lists and dictionaries with the help of TypeScript generics

Converting lists to dictionaries

TypeScript generics come in handy when you have similar lists that you need to convert to dictionaries. Consider the following lists:

const idList = [
  { id: "0001", firstName: "Levi", lastName: "Ackerman" },
  { id: "0002", firstName: "Mikasa", lastName: "Ackerman" },
  { id: "0003", firstName: "Eren", lastName: "Yeager" },
  { id: "0004", firstName: "Armin", lastName: "Arlert" },
  { id: "0005", firstName: "Annie", lastName: "Leonhart" },
]

const gameList = [
  { productCode: "G0001", title: "Space Harrier", studio: "Sega", year: 1985 },
  { productCode: "G0002", title: "Super Mario Bros", studio: "Nintendo", year: 1985 },
  { productCode: "G0003", title: "SingStar", studio: "London Studio", year: 2004 },
  { productCode: "G0004", title: "Super Stardust HD", studio: "Housemarque", year: 2007 },
]

These might need to be converted into the following types of dictionaries:

{
  '0001': { id: '0001', firstName: 'Levi', lastName: 'Ackerman' },
  ...
  '0005': { id: '0005', firstName: 'Annie', lastName: 'Leonhart' }
}

{
  G0001: { productCode: 'G0001', title: 'Space Harrier', studio: 'Sega', year: 1985 },
  ...
  G0004: { productCode: 'G0004', title: 'Super Stardust HD', studio: 'Housemarque', year: 2007 }
}

To convert the first list into a dictionary, you might create the following type of function:

const listToDict = (list: idList[], generateId: (arg: idList) => string): { [k: string]: idList} =>  {
  const dict: { [k: string]:  idList} = {};
  list.forEach(item => {
    const key = generateId(item);
    dict[key] = item;
  });
  return dict;
}

And use it like so:

console.log(listToDictGen(idList, (idList) => idList.id));

You couldn't, however, use this function to convert the second type of list, since the function is typed to match the first type of list. Here's where generics come in handy:

function listToDictGen<T>(list: T[], generateId: (arg: T) => string): { [k: string]: T} {
  const dict: { [k: string]: T} = {};
  list.forEach(item => {
    const key = generateId(item);
    dict[key] = item;
  });
  return dict;
}

So, what's changed? First, we've added the Type parameter T to the Type parameter list: function listToDictGen<T>. This tells that within the scope of this function, there exists a Type parameter T.

Next, instead of having a list parameter that takes in and array if idList items, we instead accept an array of T's. What T is may change on per invocation basis. If we pass in an array of idList items, T will be idList. If instead we pass in and array of strings, T will be string.

After that, we change the callback function that we use to generate the ID. Instead of idList, we'll use T there, too: generateId: (arg: T) => string). We will also use T when we describe the return value: { [k: string]: T} as well as the dictionary type within the function: const dict: { [k: string]: T} = {};.

After this, we can use the function for converting both the idList and the gameList by providing suitable callback functions for generating the IDs:

console.log(listToDictGen(idList, (idList) => idList.id));
console.log(listToDictGen(gameList, (gameList) => gameList.productCode));

Filtering, mapping and reducing dictionaries

Consider that you are starting with a dictionary like this:

const fruits = {
  apple: { color: "red", mass: 100 },
  grape: { color: "red", mass: 5 },
  banana: { color: "yellow", mass: 183 },
  lemon: { color: "yellow", mass: 80 },
  pear: { color: "green", mass: 178 },
  orange: { color: "orange", mass: 262 },
  raspberry: { color: "red", mass: 4 },
  cherry: { color: "red", mass: 5 },
}

To describe this dictionary, you could write an interface like this:

interface Dict<T> {
  [k: string]: T
}

Filtering the dictionary

Filtering a dictionary of type Dict<T> creates another dictionary of type Dict<T> with the help of a filtering callback function.

Now, to filter the dictionary, you would need a filter function that takes in two arguments, the dictionary to filter and a callback function to handle the filtering. The dictionary would be of type Dict<T>, the callback function would accept an argument of type T and return a boolean, and the return value of the function would be Dict<T> so we can define the function like this:

function filterDictionary<T>(input: Dict<T>, filterCb: (arg: T) => boolean): Dict<T> {}

Within the function, we create an empty dictionary of type dict<T> in which we'll store the return values. Next, we need to loop through the keys of the input dictionary with a for...in loop. We then pass each item in the callback function and check if the return value is true. If it is true, we add the item in the return value dictionary:

function filterDictionary<T>(input: Dict<T>, filterCb: (arg: T) => boolean): Dict<T> {
  const valuesToReturn: Dict<T> = {};
  
  for (let key in input) {
    if (filterCb(input[key])) {
      valuesToReturn[key] = input[key];
    }
  }

  return valuesToReturn;
}

Mapping the dictionary

Mapping a dictionary of type Dict<T> creates a dictionary of type Dict<U> with the help of a mapping callback function.

The function that we use to map a dictionary thus requires two type parameters, T and U. One version of the function might take as arguments a dictionary of type Dict<T> and a callback function that takes as arguments an item of type T and return an item of type U. The function will at end return a dictionary of type Dict<U>. We can define the function like this:

function mapDictionary<T, U>(input: Dict<T>, mappingCb: (arg: T) => U {}

Now, the function should first create a dictionary of type Dict<U> to contain the mapped items. Next, we need loop through the keys of the input dictionary with a for...in loop, and within the loop, pass each item of the input dictionary to the callback function and add the return value to the output dictionary. Finally, we'll return the output dictionary:

function mapDictionary<T, U>(input: Dict<T>, mappingCb: (arg: T) => U {
  const valuesToReturn: Dict<U> = {};
  
  for (let key of input) {
    valuesToReturn[key] = mappingCb(input[key]);
  }
  return valuesToReturn;
}

Now, we can use this mapping function to, for instance, add the mass of each fruit in kilograms into the dictionary:

const fruitsWithMassInKgs = mapDictionary(fruits, (fruit) => ({
  ...fruits,
  kg: fruit.mass * 0.001
}))

The results will look like the following:

apple: { color: 'red', mass: 100, kg: 0.1 },
grape: { color: 'red', mass: 5, kg: 0.005 },
...

What then, if we wanted to add the key used to access each dictionary item into the item, too? We could update the definition of callback function used in the mapping function to take another argument, and then pass that argument into the callback function within the for...in loop:

function mapDictionary<T, U>(input: Dict<T>, mappingCb: (arg: T, key: string) => U {
  const valuesToReturn: Dict<U> = {};
  
  for (let key of input) {
    valuesToReturn[key] = mappingCb(input[key], key);
  }
  return valuesToReturn;
}

Then, we could call the function with another argument and map the new content using it, too:

const fruitsWithMassInKgs = mapDictionary(fruits, (fruit, name) => ({
  ...fruits,
  kg: fruit.mass * 0.001,
  name
}))

The results will look like the following:

apple: { color: 'red', mass: 100, kg: 0.1, name: "apple" },
grape: { color: 'red', mass: 5, kg: 0.005, name: "grape" },
...

Reducing the dictionary

The reduce function takes in a dictionary and a function that is used to reduce for instance a certain value from each item in the dictionary into a single value. For example, we could use the reduce function to calculate the combined mass of each item in the fruits dictionary.

For the reduce function we will need a dictionary of type Dict<T>, a reducer function that takes in an item of type T and a current value of type V and returns a value of type V. At the end, the function will return a value of type V. We can define the function like so:

function reduceDictionary<T, V>(input: Dict<T>, reducer(currentValue: V, item: T) => V, initialValue: V): V {}

Within the function body, we'll need to define the value valueToReturn that gets returned at the end. This value will initially get the value from the intialValue parameter. Next, we will loop through each of the keys of the input dictionary with a for...in loop. Within the loop, we will pass each item in the input dictionary along with the valueToReturn and assign the return value to valueToReturn. After the loop has finished, we'll return valueToReturn:

function reduceDictionary<T, V>(input: Dict<T>, reducer(currentValue: V, item: T) => V, initialValue: V): V {
  const valueToReturn = initialValue;
  
  for (let key in input) {
    valueToReturn = reducer(valueToReturn, input[key]);
  }
  
  return valueToReturn;
}

We can the use this function to calculate the combined mass of the fruits in this way:

const combinedMass = reduceDictionary(fruits, (value, fruit) => value + fruit.mass, 0);

Adding constraints

We can use constraints on generic functions to make sure that the generic types that we pass into our functions meet the requirements that our functions may have. We could, for instance, define an object of type hasId using an interface when we need to make sure that the type we pass into our function has an ID. First, we define the interface:

interface hasId {
  id: string
}

After this, we can use this interface as a constraint in any generic function that we define with the extends keyword:

function listToDict<T extends hasId>(list: T[]): dict<T> {}

Now, if we would need to access the id property of T within the function, we can do it in a type-safe way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment