Having fun grouping arrays into maps with TypeScript

I love the group by SQL command, and sometimes I really miss these SQL-like functions in languages like JavaScript. In this article I explore how to group arrays into maps and how we can transform those maps into structures that work for us. We will leverage TypeScript generics to do transformation on data structures in such a way that the end-result remains strongly typed: this will make your code more maintainable.

Grouping with a callback

Let's convert an array into a Map using a callback function:

function groupBy<K, V>(array: V[], grouper: (item: V) => K) {
  return array.reduce((store, item) => {
    var key = grouper(item)
    if (!store.has(key)) {
      store.set(key, [item])
    } else {
      store.get(key).push(item)
    }
    return store
  }, new Map<K, V[]>())
}

Notice how the reduce function is used here, it uses the second parameter as its start value. The empty Map is filled as the array is reduced.

Let's see it in action:

let array = [
  { slug: "/a", metric: 4 },
  { slug: "/b", metric: 1 },
  { slug: "/a", metric: 2 },
  { slug: "/c", metric: 1 },
]

let groups = groupBy(array, x => x.slug)
console.log(groups)

This produces the following Map:

Map(3) {
  '/a' => [ { slug: '/a', metric: 4 }, { slug: '/a', metric: 2 } ],
  '/b' => [ { slug: '/b', metric: 1 } ],
  '/c' => [ { slug: '/c', metric: 1 } ]
}

Transforming Map<K,V> to Map<K,R>

Now that we have a list of metrics, we want to do some further processing. Let's see how we can calculate the total metric value.

What we want to do is to transform one map into another map. The Array object has a map function that can transform it into a different array of objects. Unfortunately there is no such API available for Map. So let's use the following function:

function transformMap<K, V, R>(
  source: Map<K, V>,
  transformer: (value: V, key: K) => R
) {
  return new Map(
    Array.from(source, v => [v[0], transformer(v[1], v[0])])
  )
}

Credits go to Sebastian -- but we changed the key , so it can be optional in an arrow expression. Let's see it in action:

let metrics = transformMap(groups, values => values.reduce((s, v) => s + v.metric, 0))
console.log(metrics)

It produces:

Map(3) { '/a' => 6, '/b' => 1, '/c' => 1 }

To combine or not to combine?

We might combine both functions into a single function:

function groupByAndMap<T, K, R>(
  array: T[],
  grouper: (x: T) => K,
  mapper: (x: T[]) => R
) {
  let groups = groupBy(array, grouper)
  return transformMap(groups, value => mapper(value))
}

console.log(
  groupByAndMap(
    array,
    x => x.slug,
    values => values.reduce((s, v) => s + v.metric, 0)
  )
)

But should we? I like to combine functions if I use them often together, as it keeps my code DRY. But where does it end? The honest answer is: you decide, but be careful not to create Swiss Army Knife functions that do anything and everything.

If you need to do multiple groupings, the code becomes a bit more compact if you use the groupByAndMap function. Let's sort an array of test results that look like this:

interface ITestResult {
  name: string,
  latency: number,
  marker: {
    threads: number
    start: Date,
    end: Date,
  }
}

let results = new Array<ITestResult>()

We want to group tests with the same name together. We further want to group results with the same number of threads. Finally, we want to sort the set by the start date and loose the extra data.

let groupedResults = groupByAndMap(
  results,
  r => r.name,
  values =>
    groupByAndMap(
      values,
      v => v.marker.threads,
      values =>
        values
          .sort((a, b) => b.marker.start - a.marker.end)
          .map(v => ({
            tests: v.tests,
            errors: v.errors,
            latency: v.latency,
            start: v.marker.start,
          }))
    )
)

Let's do the same thing without the combined function:

let groupedResults = transformMap(
  groupBy(results, r => r.name),
  values =>
    transformMap(
      groupBy(values, r => r.marker.threads),
      values =>
        values
          .sort(
            (a, b) =>
              b.marker.start - a.marker.end
          )
          .map(v => ({
            tests: v.tests,
            errors: v.errors,
            latency: v.latency,
            start: v.marker.start,
          }))
    )
)

Serializing my Map to JSON

The next thing I would like to do, is to serialize the Map to JSON. It turns out: you can't JSON serialize Maps because your key can of any type. Fortunately, our key is a string. So it is possible to convert our Map to an object that is serializable with JSON:

export function mapToObj<T>(m: Map<string, T>): { [key: string]: T } {
  return Array.from(m).reduce((obj, [key, value]) => {
    obj[key] = value
    return obj
  }, {})
}

Again, the reduce function is used to get the end-result. TypeScript will prevent you from using the function on maps that do not have the string as its key type. The resulting object is a Mapped Type, which is just a plain old object in the compiled JavaScript.

Let's see it in action:

let groups = groupByAndMap(
  array,
  x => x.slug,
  values => values.reduce((s, v) => s + v.metric, 0)
)

let groupObj = mapToObj(groups)
console.log(JSON.stringify(groupObj, null, 2))

Which results in the following JSON:

{
  "/a": 6,
  "/b": 1,
  "/c": 1
}

Transforming Map<K,V> to Array<R>?

So now we can easily transform a Map to another Map (or an object if the key is of a string type). Can we convert it to an array? Well, sure... but you need to figure out what the object should look like:

function mapToArray<K, V, R>(
  m: Map<K, V>,
  transformer: (key: K, item: V) => R
) {
  return Array.from(m.entries()).map(x =>
    transformer(x[0], x[1])
  )
}

I used this to transform a Map of Map objects to something that could be visualized by ChartJs:

let metrics = new Map([
  [
    "wehkamp",
    new Map([
      ["2021 August", 134],
      ["2021 September", 402],
      ["2021 October", 12],
    ]),
  ],
  [
    "kleertjes",
    new Map([
      ["2021 August", 75],
      ["2021 September", 247],
      ["2021 October", 6],
    ]),
  ],
])

let data = {
  labels: Array.from(metrics.get("wehkamp").keys()),
  datasets: mapToArray(metrics, (key, value) => ({
    label: key,
    data: Array.from(value.values()),
  })),
}

console.log(data)

Here, we've transformed the Map into an array of data sets. The inner maps are reduced to simple number arrays. The result looks like this:

{
  "labels": [ "2021 August", "2021 September", "2021 October"],
  "datasets": [
  {
    "label": "wehkamp",
    "data": [ 134, 402, 12 ]
  },
  {
    "label": "kleertjes",
      "data": [  75,  247, 6 ]
  }]
}

Final throughs

The main advantage of TypeScript is that it "changes" JavaScript into a strong typed language (or at least it makes it partially stronger typed 🤭). Especially when working on complex data structures, the type inference can help to make sense of the object you're working with.

Visual Studio Code has no problem to infer types after a series of groups and transforms.

It is nice to see how the Array API has become the work horse of data structures in JavaScript. The group by feature is easy to emulate in JavaScript.

Further reading

Changelog

2021-11-16 Improved the To combine or not to combine? section with examples.
2021-11-16 Changed the transformMap signature from (source: Map<K, V>, transformer: (key: K, value: V) => N) to (source: Map<K, V>, transformer: (value: V, key: K) => N) to make arrow expressions more convenient.

expand_less