groupjs_by
Zero-dependency JavaScript library for grouping arrays of objects and computing aggregates — with a chainable, SQL-inspired API.
Object.groupBy (ES2024) only buckets rows. groupjs_by is the GROUP BY + SUM / AVG / COUNT step you still write by hand.
const { groupBy } = require('groupjs_by');
groupBy(orders, 'status')
.sum('revenue', 'amount')
.avg('avgOrder', 'amount')
.count('orders')
.data;
Native Object.groupBy |
groupjs_by |
|---|---|
| Groups into arrays of items | Groups and aggregates in one chain |
| Callback key only | String field, accessor, or ['country', 'status'] |
You write a second reduce for sums |
.sum / .avg / .count / .aggregate() |
Empty input → {} |
Empty input → empty .data / .toArray() |
Why groupjs_by?
| Need | How groupjs_by helps |
|---|---|
| Group + aggregate in one place | Chain groupBy → aggregates → .data |
| Multiple dimensions | Pass ['country', 'status'] (or accessors) |
| Chart-friendly rows | Call .toArray() for [{ key, items, …aggs }] |
| Sorted tables | .orderBy('revenue', 'desc') |
| Custom metrics | .reduce(alias, fn, initial) |
| Large payloads | .omitItems() after aggregating |
| Multiple metrics efficiently | aggregate() scans each group once |
| Nested or derived keys | Pass (item) => … accessors anywhere a column is expected |
| Typed consumers | Ships with TypeScript declarations |
| Small surface area | No transitive dependencies |
Built for reporting, dashboards, ETL transforms, and any pipeline that looks like GROUP BY + SUM / AVG / MIN / MAX / COUNT.
Install
npm install groupjs_by
# or
yarn add groupjs_by
CommonJS
const { groupBy } = require('groupjs_by');
ESM
import { groupBy } from 'groupjs_by';
// or
import groupjs from 'groupjs_by';
Works in Node.js 18+ and any bundler. TypeScript types are included for both require and import.
See CHANGELOG.md for release history.
Quick start
const { groupBy } = require('groupjs_by');
const orders = [
{ status: 'paid', amount: 120, sku: 'TEE-BLK' },
{ status: 'paid', amount: 80, sku: 'HAT-RED' },
{ status: 'refunded', amount: 40, sku: 'TEE-BLK' },
];
const byStatus = groupBy(orders, 'status')
.sum('revenue', 'amount')
.avg('avgOrder', 'amount')
.count('orders')
.data;
Result
{
paid: {
items: [/* … */],
revenue: 200,
avgOrder: 100,
orders: 2,
},
refunded: {
items: [/* … */],
revenue: 40,
avgOrder: 40,
orders: 1,
},
}
Recipes
Copy-paste examples for common reporting jobs.
Sales report by status
Revenue, average order value, and order count — useful for checkout dashboards.
const { groupBy } = require('groupjs_by');
const orders = [
{ id: 'o1', status: 'paid', amount: 120.5, channel: 'web' },
{ id: 'o2', status: 'paid', amount: 89.0, channel: 'app' },
{ id: 'o3', status: 'pending', amount: 45.0, channel: 'web' },
{ id: 'o4', status: 'refunded', amount: 30.0, channel: 'web' },
{ id: 'o5', status: 'paid', amount: 210.0, channel: 'retail' },
];
// Paid / pending / refunded summary
const salesByStatus = groupBy(orders, 'status').aggregate({
sum: ['revenue', 'amount'],
avg: ['avgOrderValue', 'amount', 2],
min: ['smallestOrder', 'amount'],
max: ['largestOrder', 'amount'],
count: ['orderCount'],
}).data;
console.log(salesByStatus.paid.revenue); // 419.5
console.log(salesByStatus.paid.orderCount); // 3
// Only completed revenue (filter first)
const paidOnly = groupBy(orders, 'status')
.where((order) => order.status === 'paid')
.aggregate({
sum: ['revenue', 'amount'],
distinctCount: ['channels', 'channel'],
count: ['orderCount'],
}).data;
console.log(paidOnly.paid);
// { items: […], revenue: 419.5, channels: 3, orderCount: 3 }
Sales by channel (nested fields)
When metrics live under a nested object, use accessors:
const checkoutEvents = [
{ meta: { channel: 'web' }, payment: { total: 50 } },
{ meta: { channel: 'web' }, payment: { total: 75 } },
{ meta: { channel: 'app' }, payment: { total: 120 } },
];
const byChannel = groupBy(checkoutEvents, (row) => row.meta.channel).aggregate({
sum: ['revenue', (row) => row.payment.total],
avg: ['avgTicket', (row) => row.payment.total],
count: ['checkouts'],
}).data;
console.log(byChannel.web.revenue); // 125
console.log(byChannel.app.checkouts); // 1
Request / error logs by day
Roll up API or app logs for daily volume and error rates.
const { groupBy } = require('groupjs_by');
const logs = [
{ ts: '2026-03-01T08:12:00Z', level: 'info', route: '/api/orders', latencyMs: 42 },
{ ts: '2026-03-01T09:04:00Z', level: 'error', route: '/api/orders', latencyMs: 310 },
{ ts: '2026-03-01T18:22:00Z', level: 'info', route: '/api/cart', latencyMs: 28 },
{ ts: '2026-03-02T10:01:00Z', level: 'error', route: '/api/checkout', latencyMs: 900 },
{ ts: '2026-03-02T11:45:00Z', level: 'warn', route: '/api/orders', latencyMs: 120 },
{ ts: '2026-03-02T15:10:00Z', level: 'info', route: '/api/orders', latencyMs: 35 },
];
// Group by calendar day (UTC)
const dayKey = (log) => log.ts.slice(0, 10);
const logsByDay = groupBy(logs, dayKey).aggregate({
count: ['events'],
avg: ['avgLatencyMs', 'latencyMs', 1],
max: ['pWorstLatencyMs', 'latencyMs'],
distinctCount: ['routesHit', 'route'],
}).data;
console.log(logsByDay['2026-03-01'].events); // 3
console.log(logsByDay['2026-03-02'].avgLatencyMs); // 351.7
// Errors only, still keyed by day
const errorsByDay = groupBy(logs, dayKey)
.where((log) => log.level === 'error')
.aggregate({
count: ['errors'],
avg: ['avgErrorLatencyMs', 'latencyMs'],
distinctCount: ['failingRoutes', 'route'],
}).data;
console.log(errorsByDay['2026-03-01'].errors); // 1
console.log(errorsByDay['2026-03-02'].failingRoutes); // 1
Inventory by SKU
Stock levels, warehouse spread, and movement totals for ops / replenishment views.
const { groupBy } = require('groupjs_by');
const movements = [
{ sku: 'TEE-BLK', warehouse: 'US-EAST', qty: 40, unitCost: 8 },
{ sku: 'TEE-BLK', warehouse: 'US-WEST', qty: 12, unitCost: 8 },
{ sku: 'TEE-BLK', warehouse: 'US-EAST', qty: -5, unitCost: 8 }, // outbound
{ sku: 'HAT-RED', warehouse: 'US-EAST', qty: 20, unitCost: 15 },
{ sku: 'HAT-RED', warehouse: 'EU-CENTRAL', qty: 8, unitCost: 15 },
{ sku: 'MUG-WHT', warehouse: 'US-WEST', qty: 100, unitCost: 4 },
];
const inventoryBySku = groupBy(movements, 'sku').aggregate({
sum: ['onHand', 'qty'],
avg: ['avgUnitCost', 'unitCost'],
distinctCount: ['warehouses', 'warehouse'],
count: ['ledgerLines'],
}).data;
console.log(inventoryBySku['TEE-BLK'].onHand); // 47 (40 + 12 - 5)
console.log(inventoryBySku['TEE-BLK'].warehouses); // 2
console.log(inventoryBySku['HAT-RED'].onHand); // 28
// Low-stock SKUs only (after aggregating, filter keys you care about)
const lowStock = Object.entries(inventoryBySku)
.filter(([, row]) => row.onHand < 50)
.map(([sku, row]) => ({ sku, onHand: row.onHand, warehouses: row.warehouses }));
console.log(lowStock);
// [
// { sku: 'TEE-BLK', onHand: 47, warehouses: 2 },
// { sku: 'HAT-RED', onHand: 28, warehouses: 2 },
// ]
Inventory valuation by warehouse
Derive line value with an accessor, then sum it per warehouse:
const lineValue = (row) => row.qty * row.unitCost;
const valuation = groupBy(movements, 'warehouse')
.where((row) => row.qty > 0) // ignore outbound for on-hand value
.sum('units', 'qty')
.sum('inventoryValue', lineValue)
.distinctCount('skus', 'sku')
.count('lines')
.data;
console.log(valuation['US-EAST'].inventoryValue); // 40*8 + 20*15 = 620
console.log(valuation['US-WEST'].skus); // 2
Multi-key groupBy (country × status)
Pass an array of fields (or accessors) to group on more than one dimension:
const { groupBy } = require('groupjs_by');
const orders = [
{ country: 'US', status: 'paid', amount: 100 },
{ country: 'US', status: 'paid', amount: 50 },
{ country: 'US', status: 'refunded', amount: 20 },
{ country: 'MX', status: 'paid', amount: 80 },
];
const byCountryStatus = groupBy(orders, ['country', 'status'])
.sum('revenue', 'amount')
.count('orders');
// `.keys` returns composite key arrays
console.log(byCountryStatus.keys);
// [['US', 'paid'], ['US', 'refunded'], ['MX', 'paid']]
// `.data` uses JSON-stringified composite keys as object keys
console.log(byCountryStatus.data[JSON.stringify(['US', 'paid'])].revenue); // 150
Export rows with .toArray()
Prefer .toArray() for tables, charts, and CSV — each group becomes one object with a key field:
const rows = groupBy(orders, ['country', 'status'])
.sum('revenue', 'amount')
.count('orders')
.toArray();
console.log(rows);
// [
// { key: ['US', 'paid'], items: […], revenue: 150, orders: 2 },
// { key: ['US', 'refunded'], items: […], revenue: 20, orders: 1 },
// { key: ['MX', 'paid'], items: […], revenue: 80, orders: 1 },
// ]
// Single-key groups use a string `key`
const byStatus = groupBy(orders, 'status').count('n').toArray();
// [{ key: 'paid', items: […], n: 3 }, { key: 'refunded', items: […], n: 1 }]
Sort, custom metrics, and slim output
const leaderboard = groupBy(orders, 'status')
.sum('revenue', 'amount')
.reduce(
'skus',
(acc, order) => {
acc.push(order.sku);
return acc;
},
[]
)
.orderBy('revenue', 'desc')
.omitItems() // drop raw rows — call this last
.toArray();
// [
// { key: 'paid', revenue: 150, skus: ['A', 'B'] },
// { key: 'pending', revenue: 80, skus: ['A'] },
// …
// ]
Cookbook
Single-pass multi-aggregate
Prefer aggregate() when you need several metrics — one scan per group instead of one scan per chained method.
groupBy(orders, 'status').aggregate({
sum: ['revenue', 'amount'],
avg: ['avgOrder', 'amount'],
min: ['smallest', 'amount'],
max: ['largest', 'amount'],
distinctCount: ['skus', 'sku'],
count: ['orders'],
}).data;
| Spec key | Entry | Notes |
|---|---|---|
sum |
[alias, column] or a list of those |
Numeric sum; skips null / NaN / non-numeric |
avg |
[alias, column, decimals?] |
Mean of numeric values; defaults to 2 decimal places |
min / max |
[alias, column] |
Empty / no numeric values → null |
distinctCount |
[alias, column] |
Unique value count |
count |
[alias] |
Item count (no column) |
Pass an array of entries to compute the same op on more than one column in one scan:
groupBy(orders, 'status').aggregate({
sum: [
['revenue', 'amount'],
['tax', 'taxAmount'],
],
count: ['orders'],
}).data;
Filter before aggregating
groupBy(orders, 'status')
.where((order) => order.amount >= 100)
.sum('revenue', 'amount')
.count('orders')
.data;
Groups that become empty after where are removed. keys, firstGroup, and lastGroup stay in sync.
Empty input is valid — groupBy([], 'status') returns empty .data / .toArray(), matching Object.groupBy. Missing grouping keys land in "undefined" unless you pass { strict: true }.
Nested fields & accessors
Every column argument accepts a string key or a function:
groupBy(checkoutEvents, (row) => row.meta.channel)
.aggregate({
sum: ['revenue', (row) => row.payment.total],
count: ['checkouts'],
})
.data;
Multi-key accessors work the same way:
groupBy(rows, [
(row) => row.meta.region,
(row) => row.meta.plan,
]).sum('totalSeats', 'seats').toArray();
TypeScript
import { groupBy } from 'groupjs_by';
interface Order {
status: string;
amount: number;
}
const result = groupBy(orders, 'status')
.sum('revenue', 'amount')
.count('orders')
.data;
API
groupBy(data, key, options?)
| Param | Type | Description |
|---|---|---|
data |
T[] |
Array of objects (empty arrays return an empty result) |
key |
string | (item) => any | Array<string | (item) => any> |
Single field, accessor, or multi-key list |
options.strict |
boolean |
If true, throw when a string key is missing on any object. Default: missing keys group under "undefined". |
Returns a chainable GroupResult. Throws if data is not an array, or if key is missing.
For multi-key grouping, .data stores groups under JSON.stringify(keyParts). Prefer .keys or .toArray() when consuming composite keys.
Aggregates
All aggregate methods (except count) take an alias (output property name) and a column (string or accessor). They return this for chaining.
| Method | Signature | Empty group |
|---|---|---|
.sum |
(alias, column) |
0 |
.avg |
(alias, column, decimals = 2) |
null |
.min |
(alias, column) |
null |
.max |
(alias, column) |
null |
.distinctCount |
(alias, column) |
0 |
.count |
(alias) |
0 |
.aggregate |
(spec) |
Same rules per metric |
.reduce |
(alias, reducer, initial?) |
Same as Array#reduce |
sum / avg / min / max skip null, undefined, '', and non-finite values so mixed JSON does not poison a group with NaN. Numeric strings (e.g. '4') still coerce. avg divides by the count of numeric values, not the raw group size.
.aggregate({
sum: ['total', 'amount'],
avg: ['mean', 'amount', 2],
min: ['lo', 'amount'],
max: ['hi', 'amount'],
distinctCount: ['skus', 'sku'],
count: ['n'],
})
// Custom per-group fold (array/object initials are shallow-cloned per group)
.reduce('skus', (acc, row) => { acc.push(row.sku); return acc; }, [])
.reduce('product', (acc, row) => acc * row.amount, 1)
.orderBy(field, direction = 'asc')
Reorder groups (updates .data key order, .keys, and .toArray()).
| Param | Type | Description |
|---|---|---|
field |
'key' | string | (group, key) => any |
Sort by group key, an aggregate alias, or a custom value |
direction |
'asc' | 'desc' |
Default 'asc'; nullish values sort last on asc |
.orderBy('revenue', 'desc')
.orderBy('key', 'asc')
.orderBy((group) => group.n, 'desc')
.omitItems()
Deletes items from every group to free memory. Call after aggregations / where / reduce. Further item-based ops throw.
.where(predicate)
| Param | Type | Description |
|---|---|---|
predicate |
(item: T) => boolean |
Keep item when truthy |
Filters items inside each group. Empty groups are deleted from .data.
.toArray()
Returns an array of group rows (insertion order):
[{ key, items, ...aliases }]
- Single-key:
keyis a string (same asObject.keyson.data) - Multi-key:
keyis an array of part values, e.g.['US', 'paid']
Useful for sorting, mapping to UI tables, or serializing without composite object keys.
Result properties
| Property | Type | Description |
|---|---|---|
.data |
object |
{ [groupKey]: { items, …aliases } } |
.keys |
any[] |
Current group keys (strings, or arrays when multi-key) |
.firstGroup |
T[] |
Items in the first group |
.lastGroup |
T[] |
Items in the last group |
keys, firstGroup, and lastGroup reflect the latest state after where.
Performance notes
min/maxuse tight loops (safe on groups with 100k+ rows — no argument-spread stack limits).distinctCountuses aSet(linear in group size).aggregate()is the fastest path when computing several metrics together.
Local micro-benchmark (optional, not a CI gate):
npm run bench
Compatibility
- Runtime: Node.js 18+ (CommonJS + ESM); browsers via bundlers
- Entry points:
require('groupjs_by')→index.js;import→index.mjs - Dependencies: none
- Types: bundled (
index.d.ts/index.d.mts)
Contributing
git clone https://github.com/juli04guilar/groupBy.git
cd groupBy
yarn install # or npm install
npm test
Pull requests and issues are welcome.