DEV Community

Julio Aguilar
Julio Aguilar

Posted on

I Built a JavaScript groupBy Library — Here's What I Learned

The problem: Object.groupBy() gets you halfway there

ES2024 finally gave JavaScript a native Object.groupBy(). It's great no more hand-rolled reduce just to
bucket an array by a key.

But in real applications, grouping is rarely the end goal. The moment you group orders by status, someone asks for revenue per status. Group logs by day, and you need error counts per day. Group inventory by SKU, and you need total units on hand per SKU.

Object.groupBy() stops exactly where the interesting work begins. It hands you arrays of raw items and leaves
the summing, averaging, and counting to you — every single time.

Why I stopped writing the same reduce over and over

I noticed I was writing this pattern constantly, in slightly different shapes, across dashboards, reporting endpoints, and ETL scripts:

const byStatus = Object.groupBy(orders, (o) => o.status);

  const result = {};
  for (const status in byStatus) {
    const items = byStatus[status];
    result[status] = {
      revenue: items.reduce((sum, o) => sum + o.amount, 0),
      avgOrder: items.reduce((sum, o) => sum + o.amount, 0) / items.length,
      orders: items.length,
    };
  }
Enter fullscreen mode Exit fullscreen mode

It works, but it's brittle — every new metric is another reduce pass, and nothing guards against a null amount quietly turning the whole sum into NaN. I'd copy-pasted this pattern into enough projects that it stopped feeling like "just a reduce" and started feeling like a missing standard library function — the GROUP BY + SUM/AVG/COUNT step SQL gives you for free. So I built groupjs_by: a small, zero-dependency library that picks up exactly where Object.groupBy() leaves off.

API design: group, then aggregate, in one chain

const { groupBy } = require('groupjs_by');

  groupBy(orders, 'status')
    .sum('revenue', 'amount')
    .avg('avgOrder', 'amount')
    .count('orders')
    .data;
Enter fullscreen mode Exit fullscreen mode

That's the whole surface, more or less:

-groupBy(data, key, options?) — groups by a field name, an accessor function, or an array of both for multi-key grouping (['country', 'status']).

  • sum(alias, column), .avg(alias, column, decimals?),
  • min(alias, column), max(alias, column),distinctCount(alias, column)
  • count(alias) — the aggregates you'd write by hand, as one-liners.
  • where(predicate) — filters items inside each group before you aggregate.
  • aggregate(spec) — computes several metrics in a single pass instead of one scan per method.
  • reduce(alias, reducer, initial?) — an escape hatch for custom per-group folds.
  • orderBy(field, direction?) and .toArray() — for turning groups into sorted, chart-ready rows.

Every column argument accepts a string key or an accessor function, so nested or derived values work without flattening your data first:

groupBy(checkoutEvents, (row) => row.meta.channel).aggregate({
    sum: ['revenue', (row) => row.payment.total],
    count: ['checkouts'],
 }).data;
Enter fullscreen mode Exit fullscreen mode

Why chaining, specifically

A single config object (groupBy(data, { key, aggregates: {...} })) is arguably more "data-driven," but reporting code gets built up incrementally — you group, look at the shape, then decide you also want an average, then a distinct count of something. Chaining mirrors that workflow and reads close to the SQL you're mentally translating from:


  SELECT status, SUM(amount) AS revenue, AVG(amount) AS avg_order, COUNT(*) AS orders
  FROM orders GROUP BY status;
Enter fullscreen mode Exit fullscreen mode
 groupBy(orders, 'status')
    .sum('revenue', 'amount')
    .avg('avgOrder', 'amount')
    .count('orders');
Enter fullscreen mode Exit fullscreen mode

The one deliberate escape hatch is .aggregate(spec), which takes a plain object instead of a chain purely for performance, since chaining
.sum().avg().min().max()
scans each group once per method, while .aggregate()scans each group once.

Output format: built for tables and dashboards, not just objects

.data gives you { [groupKey]: { items, ...aggregates } } , good for direct lookups, but awkward to map over for a UI table. So every result also exposes .toArray():

groupBy(orders, ['country', 'status'])
    .sum('revenue', 'amount')
    .count('orders')
    .toArray();

  // [
  //   { key: ['US', 'paid'], items: [...], revenue: 150, orders: 2 },
  //   { key: ['US', 'refunded'], items: [...], revenue: 20, orders: 1 },
  //   { key: ['MX', 'paid'], items: [...], revenue: 80, orders: 1 },
  // ]
Enter fullscreen mode Exit fullscreen mode

Pair that with .orderBy('revenue', 'desc')and you have exactly the row shape a dashboard table or chart
expects .omitItems() (called last) strips the raw items before you serialize the result over the wire.

What I learned shipping an npm package

A few things surprised me more than the aggregation logic itself:

  • Dual CJS/ESM output is not optional anymore. Between require('groupjs_by'), import { groupBy } from 'groupjs_by', and bundlers that care about "exports" conditions, I ended up maintaining index.js / index.mjs and matching .d.ts / .d.mts type declarations, wired through package.json's exports map for both import and require resolution.
  • coverageThreshold in Jest is a good forcing function. Setting functions: 100, lines: 100, statements: 100 early meant every edge case I described above had to be exercised by a real test, not just handled optimistically in the code.
  • CI is where you find the YAML mistakes, not the JS ones. More than one release cycle here was "fix invalid YAML in the workflow" rather than a code fix — worth budgeting time for.
  • Trusted Publishing (OIDC) beats a long-lived NPM_TOKEN. Wiring GitHub Actions to publish via npm's OIDC trust relationship instead of a stored token was a small setup cost that removes a credential I'd otherwise have to rotate.
  • The README is a design document. Writing the "recipes" section — sales reports, log rollups, inventoryvaluation — before locking the API surface surfaced awkward method signatures earlier than writing tests did.

Try it

groupjs_by is zero-dependency, ships TypeScript types out of the box, and works in Node 18+ via CommonJS or ESM.

npm install groupjs_by

If you've been writing the same reduce for sums and averages after every Object.groupBy() call, I'd love feedback — issues and PRs are open, and I'm especially curious what aggregate types people find themselves reaching for beyond sum/avg/min/max/distinctCount.

Top comments (0)