groupjs_by
v2.0.5
Published
Zero-dependency groupBy and aggregates for arrays of objects (sum, avg, min, max, count)
Maintainers
Readme
groupjs_by
Zero-dependency JavaScript library for grouping arrays of objects and computing aggregates — with a chainable, SQL-inspired API.
const { groupBy } = require('groupjs_by');
groupBy(orders, 'status')
.sum('revenue', 'amount')
.avg('avgOrder', 'amount')
.count('orders')
.data;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_byCommonJS
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); // 1Request / 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); // 1Inventory 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); // 2Multi-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); // 150Export 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] | Numeric sum |
| avg | [alias, column, decimals?] | Defaults to 2 decimal places |
| min / max | [alias, column] | Empty groups → null |
| distinctCount | [alias, column] | Unique value count |
| count | [alias] | Item count (no column) |
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.
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 groupjs = require('groupjs_by');
interface Order {
status: string;
amount: number;
}
const result = groupjs.groupBy(orders as Order[], 'status')
.sum('revenue', 'amount')
.count('orders')
.data;API
groupBy(data, key)
| Param | Type | Description |
|--------|------|-------------|
| data | T[] | Non-empty array of objects |
| key | string \| (item) => any \| Array<string \| (item) => any> | Single field, accessor, or multi-key list |
Returns a chainable GroupResult. Throws if data is empty / not an array, or if a string key is missing on any object.
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 |
.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 benchCompatibility
- 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 testPull requests and issues are welcome.
