npm.io
2.1.1 • Published 1 month ago

groupjs_by

Licence
MIT
Version
2.1.1
Deps
0
Size
64 kB
Vulns
0
Weekly
0
Stars
1

groupjs_by

npm version npm downloads CI License: MIT TypeScript

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: key is a string (same as Object.keys on .data)
  • Multi-key: key is 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 / max use tight loops (safe on groups with 100k+ rows — no argument-spread stack limits).
  • distinctCount uses a Set (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.


License

MIT Julio Aguilar

Keywords