JSON Group By JavaScript: reduce, Object.groupBy, lodash

Last updated:

Grouping a JSON array by a property uses Array.reduce() to build an object keyed by the group value — array.reduce((acc, item) => { (acc[item.category] ??= []).push(item); return acc; }, {}) groups items by category in one pass. ES2024 introduces Object.groupBy(array, item => item.category) — natively supported in Node.js 21+, Chrome 117+, and Safari 17.4+ — returning a null-prototype object. For older environments, use Map.groupBy() which returns a true Map (preserving insertion order for non-string keys).

This guide covers the reduce() groupBy pattern, ES2024 Object.groupBy() and Map.groupBy(), lodash _.groupBy(), multi-level nested grouping, aggregating group totals with reduce, and SQL GROUP BY analogies for developers familiar with relational databases. Related topics: JSON transform, JSON search and filter, JSON sorting, and JSON performance.

Array.reduce() groupBy Pattern

Array.reduce() is the universal groupBy tool — it works in every JavaScript environment from IE9 onward and requires no dependencies. The pattern accumulates items into an object where each key is a group value and each value is an array of matching items. Two flavors exist: a mutable pattern (faster, preferred for large arrays) and an immutable pattern (safer for pure functional pipelines).

const products = [
  { id: 1, name: 'Laptop',   category: 'electronics', price: 999 },
  { id: 2, name: 'T-Shirt',  category: 'clothing',    price: 29  },
  { id: 3, name: 'Phone',    category: 'electronics', price: 699 },
  { id: 4, name: 'Jeans',    category: 'clothing',    price: 59  },
  { id: 5, name: 'Headphones', category: 'electronics', price: 199 },
]

// ── Mutable pattern (recommended for performance) ──────────────────
// ??= (nullish assignment): initialise array on first encounter, then push
const grouped = products.reduce((acc, item) => {
  (acc[item.category] ??= []).push(item)
  return acc
}, {})
// {
//   electronics: [ { id:1,... }, { id:3,... }, { id:5,... } ],
//   clothing:    [ { id:2,... }, { id:4,... } ]
// }

// ── Immutable pattern (spread — pure functional, slower) ────────────
const groupedImmutable = products.reduce((acc, item) => ({
  ...acc,
  [item.category]: [...(acc[item.category] ?? []), item],
}), {})
// Same output — but O(n²) due to spread; avoid for arrays > 1 000 items

// ── Generic reusable groupBy helper ────────────────────────────────
function groupBy(array, keyFn) {
  return array.reduce((acc, item) => {
    const key = keyFn(item)
    ;(acc[key] ??= []).push(item)
    return acc
  }, {})
}

// Usage with string key
const byCategory = groupBy(products, p => p.category)

// Usage with computed key — group by price range
const byPriceRange = groupBy(products, p => {
  if (p.price < 50)  return 'budget'
  if (p.price < 500) return 'mid-range'
  return 'premium'
})
// { mid-range: [...], premium: [...] }

// ── Group by boolean predicate ──────────────────────────────────────
const byExpensive = groupBy(products, p => p.price >= 500 ? 'expensive' : 'affordable')
// { expensive: [ Laptop, Phone ], affordable: [ T-Shirt, Jeans, Headphones ] }

// ── Group by nested property ────────────────────────────────────────
const orders = [
  { id: 1, user: { country: 'US' }, total: 100 },
  { id: 2, user: { country: 'GB' }, total: 200 },
  { id: 3, user: { country: 'US' }, total: 150 },
]
const byCountry = groupBy(orders, o => o.user.country)
// { US: [ {id:1,...}, {id:3,...} ], GB: [ {id:2,...} ] }

The mutable ??= pattern is O(n) — each item is processed once and pushed into a pre-existing array reference. The immutable spread pattern is O(n²) because every iteration copies all existing items; for 10 000 items it performs roughly 50 million copy operations. Use the mutable pattern in production. The immutable pattern is acceptable only in purely functional pipelines where referential transparency is enforced and arrays are small (under 1 000 items).

Object.groupBy() (ES2024): Native Grouping

Object.groupBy() is the native ES2024 solution — a static method on Object that eliminates the need for a custom reduce() helper. It accepts any iterable (array, Set, generator) as the first argument and a callback returning the group key as the second. Browser support: Chrome 117+, Firefox 119+, Safari 17.4+, Node.js 21+. For Node.js 18-20, use the reduce() pattern or a polyfill.

const products = [
  { id: 1, name: 'Laptop',     category: 'electronics', status: 'active'   },
  { id: 2, name: 'T-Shirt',   category: 'clothing',    status: 'active'   },
  { id: 3, name: 'Phone',     category: 'electronics', status: 'inactive' },
  { id: 4, name: 'Jeans',     category: 'clothing',    status: 'active'   },
]

// ── Basic Object.groupBy() ──────────────────────────────────────────
const byCategory = Object.groupBy(products, item => item.category)
// {
//   electronics: [ { id:1,... }, { id:3,... } ],
//   clothing:    [ { id:2,... }, { id:4,... } ]
// }

// ── The returned object has a null prototype ────────────────────────
// It does NOT inherit Object.prototype — no hasOwnProperty, toString, etc.
console.log(Object.getPrototypeOf(byCategory))  // null
// Safe to use as a key-value store with arbitrary keys (no prototype pollution)

// ── Iterating the result ────────────────────────────────────────────
// Object.keys / Object.values / Object.entries work normally
Object.entries(byCategory).forEach(([category, items]) => {
  console.log(`${category}: ${items.length} items`)
})
// electronics: 2 items
// clothing: 2 items

// ── With computed keys ──────────────────────────────────────────────
const byStatus = Object.groupBy(products, item =>
  item.status === 'active' ? 'available' : 'unavailable'
)

// ── Grouping a Set ──────────────────────────────────────────────────
const tags = new Set(['json', 'javascript', 'json', 'typescript', 'javascript'])
// Object.groupBy works on any iterable, including Set
const letters = Object.groupBy(tags, tag => tag[0])
// { j: ['json', 'javascript'], t: ['typescript'] }

// ── Polyfill for Node.js 18–20 or older browsers ───────────────────
if (!Object.groupBy) {
  Object.groupBy = (iterable, keyFn) =>
    [...iterable].reduce((acc, item) => {
      const key = keyFn(item)
      ;(acc[key] ??= []).push(item)
      return acc
    }, Object.create(null))  // null prototype to match spec
}

// ── TypeScript typing ────────────────────────────────────────────────
// Object.groupBy is typed in TypeScript 5.4+ (lib: ES2024)
// Result type: { [key: string]: Product[] }
// or more precisely: Partial<Record<string, Product[]>>
// Groups may not include all possible keys — use optional chaining:
const electronicsItems = byCategory['electronics']  // Product[] | undefined
const safeItems = byCategory['electronics'] ?? []

A key detail: Object.groupBy() returns a null-prototype object (created with Object.create(null)), not a plain { } object literal. This means byCategory.hasOwnProperty is undefined — call Object.hasOwn(byCategory, 'electronics') instead. TypeScript 5.4+ includes type definitions for Object.groupBy() when targeting ES2024 in tsconfig.json; the return type is Partial<Record<string, T[]>> because not every key is guaranteed to be present.

Map.groupBy(): Preserving Key Types

Map.groupBy() was introduced alongside Object.groupBy() in ES2024 and solves the key type-coercion problem: because plain object keys are always strings, grouping by Date objects, Numbers-as-references, or complex objects loses the original key type. Map.groupBy() returns a true Map that preserves key identity using SameValueZero equality.

const events = [
  { id: 1, name: 'Deploy',   date: new Date('2026-05-01'), type: 'ops'  },
  { id: 2, name: 'Release',  date: new Date('2026-05-01'), type: 'dev'  },
  { id: 3, name: 'Incident', date: new Date('2026-05-15'), type: 'ops'  },
  { id: 4, name: 'Review',   date: new Date('2026-05-15'), type: 'dev'  },
]

// ── Problem with Object.groupBy + Date keys ─────────────────────────
// Date keys are coerced to their toString() representation
const objGrouped = Object.groupBy(events, e => e.date)
// Keys are strings like "Thu May 01 2026 00:00:00 GMT+0000..."
// Cannot look up by Date object — type is lost

// ── Map.groupBy preserves Date as Map key ──────────────────────────
const mapGrouped = Map.groupBy(events, e => e.date)
// mapGrouped is a Map<Date, Event[]>
// But: two distinct Date objects with the same time value are NOT equal
// Map uses SameValueZero — two Date objects are the same key only if same reference

// ── Correct: use a canonical string key derived from the Date ────────
const byDay = Map.groupBy(events, e => e.date.toISOString().slice(0, 10))
// Map { '2026-05-01' => [Deploy, Release], '2026-05-15' => [Incident, Review] }

// ── Iterating a Map result ──────────────────────────────────────────
for (const [day, items] of byDay) {
  console.log(`${day}: ${items.map(i => i.name).join(', ')}`)
}
// 2026-05-01: Deploy, Release
// 2026-05-15: Incident, Review

// ── Lookup by key ────────────────────────────────────────────────────
const may1Events = byDay.get('2026-05-01')  // [Deploy, Release]
// With Object, byCategory['2026-05-01'] also works — string keys are fine either way

// ── When Map.groupBy shines: non-string enum-like keys ──────────────
const PRIORITY = { LOW: 1, MEDIUM: 2, HIGH: 3 }
const tickets = [
  { id: 1, title: 'Bug A', priority: PRIORITY.HIGH   },
  { id: 2, title: 'Bug B', priority: PRIORITY.LOW    },
  { id: 3, title: 'Bug C', priority: PRIORITY.HIGH   },
  { id: 4, title: 'Bug D', priority: PRIORITY.MEDIUM },
]

const byPriority = Map.groupBy(tickets, t => t.priority)
// Map { 3 => [Bug A, Bug C], 1 => [Bug B], 2 => [Bug D] }
// Keys are numbers — preserved as numbers in Map, but coerced to strings in Object

// Iterate in insertion order
for (const [priority, items] of byPriority) {
  console.log(`Priority ${priority}: ${items.length} tickets`)
}

// Convert Map back to plain object if needed
const asObject = Object.fromEntries(byPriority)
// { '3': [...], '1': [...], '2': [...] }  ← note: keys become strings here

The critical insight: Map.groupBy() uses SameValueZero equality for Map key identity — two separate new Date('2026-05-01') objects are different Map keys even though they represent the same instant. For Date-based grouping, always derive a string key (ISO date slice, Unix timestamp as string) rather than using the Date object directly as a key. Use Map.groupBy() primarily when numeric keys must remain numbers or when you need guaranteed insertion-order iteration over non-string key types.

lodash _.groupBy(): Shorthand Grouping

lodash _.groupBy() has been the de facto groupBy solution since 2012 and works in all environments from Node.js 0.10 to the latest browsers. It accepts a string property name (shorthand iteratee) or a full callback function, mirrors the Array.reduce() mutable output format, and pairs naturally with other lodash collection methods like _.mapValues(), _.countBy(), and _.orderBy().

import _ from 'lodash'
// Or: import groupBy from 'lodash/groupBy'  — tree-shakeable single-function import

const products = [
  { id: 1, name: 'Laptop',     category: 'electronics', status: 'active',   price: 999 },
  { id: 2, name: 'T-Shirt',   category: 'clothing',    status: 'active',   price: 29  },
  { id: 3, name: 'Phone',     category: 'electronics', status: 'inactive', price: 699 },
  { id: 4, name: 'Jeans',     category: 'clothing',    status: 'active',   price: 59  },
  { id: 5, name: 'Headphones', category: 'electronics', status: 'active',  price: 199 },
]

// ── String shorthand iteratee ───────────────────────────────────────
const byCategory = _.groupBy(products, 'category')
// { electronics: [...], clothing: [...] }

// ── Function iteratee ───────────────────────────────────────────────
const byPriceRange = _.groupBy(products, p => {
  if (p.price < 100)  return 'budget'
  if (p.price < 500)  return 'mid-range'
  return 'premium'
})
// { budget: [T-Shirt, Jeans], mid-range: [Headphones], premium: [Laptop, Phone] }

// ── _.countBy — count items per group directly ──────────────────────
const countPerCategory = _.countBy(products, 'category')
// { electronics: 3, clothing: 2 }
// Equivalent to groupBy + mapValues(items => items.length)

// ── Combine groupBy with mapValues for aggregation ──────────────────
const grouped = _.groupBy(products, 'category')

// Count per group
const counts = _.mapValues(grouped, items => items.length)
// { electronics: 3, clothing: 2 }

// Sum prices per group
const totals = _.mapValues(grouped, items => _.sumBy(items, 'price'))
// { electronics: 1897, clothing: 88 }

// Average price per group
const averages = _.mapValues(grouped, items => _.meanBy(items, 'price'))
// { electronics: 632.33, clothing: 44 }

// ── Full summary per group ──────────────────────────────────────────
const summary = _.mapValues(grouped, items => ({
  count:   items.length,
  total:   _.sumBy(items, 'price'),
  average: _.meanBy(items, 'price'),
  min:     _.minBy(items, 'price')?.price,
  max:     _.maxBy(items, 'price')?.price,
}))

// ── Chain groupBy with other lodash operations ──────────────────────
const result = _(products)
  .groupBy('category')
  .mapValues(items => _.orderBy(items, ['price'], ['desc']))  // sort within group
  .value()

// ── lodash-es for ESM / tree-shaking ───────────────────────────────
// import { groupBy, countBy, mapValues, sumBy } from 'lodash-es'
// Tree-shakeable — only the functions you import are bundled

The string shorthand iteratee — _.groupBy(array, 'status') — is lodash's most convenient feature: no arrow function boilerplate for simple property access. For bundle size, prefer import groupBy from 'lodash/groupBy' (CommonJS single function) or import { groupBy } from 'lodash-es' (ESM tree-shakeable). Importing the entire lodash package adds ~70 KB gzipped; importing only groupBy from lodash-es adds under 2 KB. Use _.countBy() instead of _.groupBy() + _.mapValues(items => items.length) when you only need counts — it is faster and more direct.

Multi-Level Nested Grouping

Multi-level grouping nests groups within groups — equivalent to SQL GROUP BY category, status. Two approaches: composite string keys (flat object, simpler) and nested object grouping (hierarchical, enables independent access at each level). The nested approach is more memory-efficient when many combinations share outer keys; composite keys are simpler to iterate.

const products = [
  { id: 1, category: 'electronics', status: 'active',   region: 'US', price: 999 },
  { id: 2, category: 'clothing',    status: 'active',   region: 'EU', price: 29  },
  { id: 3, category: 'electronics', status: 'inactive', region: 'US', price: 699 },
  { id: 4, category: 'clothing',    status: 'active',   region: 'US', price: 59  },
  { id: 5, category: 'electronics', status: 'active',   region: 'EU', price: 199 },
]

// ── Approach 1: Composite key (flat, simple) ─────────────────────────
const byCompKey = products.reduce((acc, p) => {
  const key = `${p.category}__${p.status}`
  ;(acc[key] ??= []).push(p)
  return acc
}, {})
// { 'electronics__active': [...], 'electronics__inactive': [...], 'clothing__active': [...] }

// Iterate composite keys
Object.entries(byCompKey).forEach(([key, items]) => {
  const [category, status] = key.split('__')
  console.log(`${category} / ${status}: ${items.length}`)
})

// ── Approach 2: Nested groupBy (hierarchical) ─────────────────────
// Group by category, then within each category group by status
const nested = Object.fromEntries(
  Object.entries(Object.groupBy(products, p => p.category))
    .map(([category, items]) => [
      category,
      Object.groupBy(items, p => p.status),
    ])
)
// {
//   electronics: { active: [{id:1},{id:5}], inactive: [{id:3}] },
//   clothing:    { active: [{id:2},{id:4}] }
// }

// Access individual group
const activeElectronics = nested['electronics']?.['active'] ?? []
// [ { id:1,... }, { id:5,... } ]

// ── Three-level grouping ──────────────────────────────────────────
const tripleNested = Object.fromEntries(
  Object.entries(Object.groupBy(products, p => p.category)).map(([cat, catItems]) => [
    cat,
    Object.fromEntries(
      Object.entries(Object.groupBy(catItems, p => p.status)).map(([status, statusItems]) => [
        status,
        Object.groupBy(statusItems, p => p.region),
      ])
    ),
  ])
)
// nested['electronics']['active']['US'] → [ {id:1,...} ]

// ── Generic recursive groupByMultiple helper ──────────────────────
function groupByMultiple(array, keys) {
  if (keys.length === 0) return array
  const [first, ...rest] = keys
  const grouped = Object.groupBy(array, item => item[first])
  return Object.fromEntries(
    Object.entries(grouped).map(([key, items]) => [
      key,
      groupByMultiple(items, rest),
    ])
  )
}

const result = groupByMultiple(products, ['category', 'status', 'region'])
// result['electronics']['active']['EU'] → [ { id:5,... } ]

Choose composite keys when the downstream code treats groups as a flat lookup table and the number of possible key combinations is small. Choose nested grouping when you need to iterate or aggregate at intermediate levels — for example, summing totals per category before drilling into status. The recursive groupByMultiple helper is useful for dynamic grouping where the set of keys is determined at runtime (e.g., user-selected pivot dimensions in a reporting UI).

Aggregating After Grouping: Count, Sum, and Average

Grouping alone produces buckets of items; aggregation computes summary statistics from those buckets. The standard pipeline is: group with reduce() or Object.groupBy(), then map the grouped object to summary rows with Object.entries().map(). For maximum performance on large datasets, compute all aggregates in a single reduce() pass without creating intermediate group arrays.

const sales = [
  { id: 1, product: 'Laptop',  category: 'electronics', qty: 2, price: 999 },
  { id: 2, product: 'T-Shirt', category: 'clothing',    qty: 5, price: 29  },
  { id: 3, product: 'Phone',   category: 'electronics', qty: 1, price: 699 },
  { id: 4, product: 'Jeans',   category: 'clothing',    qty: 3, price: 59  },
  { id: 5, product: 'Cable',   category: 'electronics', qty: 10, price: 19 },
]

// ── Two-pass: group then aggregate ─────────────────────────────────
const grouped = Object.groupBy(sales, s => s.category)

const summary = Object.entries(grouped).map(([category, items]) => ({
  category,
  count:   items.length,
  totalQty: items.reduce((sum, s) => sum + s.qty, 0),
  revenue:  items.reduce((sum, s) => sum + s.qty * s.price, 0),
  avgPrice: items.reduce((sum, s) => sum + s.price, 0) / items.length,
}))
// [
//   { category: 'electronics', count: 3, totalQty: 13, revenue: 2885, avgPrice: 572.33 },
//   { category: 'clothing',    count: 2, totalQty: 8,  revenue: 322,  avgPrice: 44 }
// ]

// ── Single-pass: compute aggregates during grouping ──────────────────
// Avoids O(n) pass per aggregate — ideal for large arrays
const singlePass = Object.values(
  sales.reduce((acc, s) => {
    const g = (acc[s.category] ??= {
      category: s.category, count: 0, totalQty: 0, revenue: 0, sumPrice: 0,
    })
    g.count++
    g.totalQty += s.qty
    g.revenue  += s.qty * s.price
    g.sumPrice += s.price
    return acc
  }, {})
).map(g => ({ ...g, avgPrice: g.sumPrice / g.count }))

// ── Count only — no group arrays needed ────────────────────────────
const counts = sales.reduce((acc, s) => {
  acc[s.category] = (acc[s.category] ?? 0) + 1
  return acc
}, {})
// { electronics: 3, clothing: 2 }

// ── Sort summary by revenue descending ─────────────────────────────
const ranked = summary.sort((a, b) => b.revenue - a.revenue)

// ── Percentage of total ─────────────────────────────────────────────
const totalRevenue = summary.reduce((sum, g) => sum + g.revenue, 0)
const withShare = summary.map(g => ({
  ...g,
  revenueShare: ((g.revenue / totalRevenue) * 100).toFixed(1) + '%',
}))

// ── Flatten grouped summary back to array ──────────────────────────
// Useful when the consumer expects a flat array of { key, ...stats } objects
const flat = Object.entries(grouped).flatMap(([category, items]) =>
  items.map(item => ({ ...item, groupCount: items.length }))
)

The single-pass pattern is the most efficient for large datasets — it iterates the array exactly once and computes all aggregates simultaneously. Use it when the array has more than 10 000 items or when aggregating in a hot code path. For smaller arrays, the two-pass approach (group then aggregate) is more readable and easier to maintain. Always compute the average after the reduce pass — dividing inside the reduce accumulates floating-point errors that compound with each division.

groupBy vs SQL GROUP BY: Analogies for SQL Developers

JavaScript groupBy is functionally equivalent to SQL GROUP BY but operates on in-memory arrays rather than database rows. Understanding the mapping between SQL clauses and JavaScript array methods makes it easier to translate reporting queries into frontend or Node.js data transformations without hitting the database.

// SQL equivalent → JavaScript Array equivalent
//
// SELECT category, COUNT(*) as count
// FROM products
// GROUP BY category
//
const sqlCountAnalog = Object.entries(
  Object.groupBy(products, p => p.category)
).map(([category, items]) => ({ category, count: items.length }))

// ─────────────────────────────────────────────────────────────────
// SQL: SELECT category, SUM(price) as total, AVG(price) as avg
//      FROM products GROUP BY category
//
const sqlSumAvgAnalog = Object.entries(
  Object.groupBy(products, p => p.category)
).map(([category, items]) => ({
  category,
  total: items.reduce((s, p) => s + p.price, 0),
  avg:   items.reduce((s, p) => s + p.price, 0) / items.length,
}))

// ─────────────────────────────────────────────────────────────────
// SQL: SELECT category, COUNT(*) as count
//      FROM products
//      WHERE status = 'active'         ← WHERE (before grouping)
//      GROUP BY category
//      HAVING COUNT(*) > 1             ← HAVING (after grouping)
//      ORDER BY count DESC             ← ORDER BY
//
const sqlFullAnalog = Object.entries(
  Object.groupBy(
    products.filter(p => p.status === 'active'),  // WHERE
    p => p.category
  )
)
  .map(([category, items]) => ({ category, count: items.length }))
  .filter(g => g.count > 1)              // HAVING
  .sort((a, b) => b.count - a.count)     // ORDER BY count DESC

// ─────────────────────────────────────────────────────────────────
// SQL: SELECT category, status, COUNT(*) as count
//      FROM products GROUP BY category, status
//
const sqlMultiGroupBy = Object.entries(
  products.reduce((acc, p) => {
    const key = `${p.category}__${p.status}`
    ;(acc[key] ??= []).push(p)
    return acc
  }, {})
).map(([key, items]) => {
  const [category, status] = key.split('__')
  return { category, status, count: items.length }
})

// ─────────────────────────────────────────────────────────────────
// SQL: SELECT p.category, COUNT(o.id) as orderCount
//      FROM products p JOIN orders o ON p.id = o.product_id
//      GROUP BY p.category
//
// JavaScript: join arrays first (simulate JOIN), then group
const orders = [
  { id: 1, productId: 1, qty: 2 },
  { id: 2, productId: 1, qty: 1 },
  { id: 3, productId: 2, qty: 5 },
]
const productMap = new Map(products.map(p => [p.id, p]))

const joinedOrders = orders.map(o => ({
  ...o,
  category: productMap.get(o.productId)?.category,
}))
const ordersByCategory = Object.groupBy(joinedOrders, o => o.category)

The SQL-to-JavaScript mapping is: WHERE clause maps to Array.filter() before grouping; GROUP BY maps to Object.groupBy() or reduce(); aggregate functions (COUNT, SUM, AVG) map to Array.reduce() within each group; HAVING maps to Array.filter() after grouping; ORDER BY maps to Array.sort(). Multi-column GROUP BY maps to composite string keys or nested grouping. SQL JOIN maps to building a Map lookup and using Array.map() to denormalize before grouping.

Key Terms

Array.reduce() groupBy
A pattern that uses Array.prototype.reduce() to accumulate array items into an object keyed by a group value. The accumulator starts as an empty object ({ }) and each iteration either initialises a new array for a new key or pushes the item onto an existing array. The mutable pattern — (acc[key] ??= []).push(item) — is O(n) and preferred for production. The immutable spread pattern — ({ ...acc, [key]: [...(acc[key] ?? []), item] }) — is O(n²) due to repeated copying and should only be used for small arrays in functional pipelines. Works in all JavaScript environments from ES5 onward.
Object.groupBy()
A static method introduced in ES2024 that groups the elements of an iterable into a plain object. Syntax: Object.groupBy(iterable, keyFn). The keyFn callback receives each element and returns its group key; keys are coerced to strings. Returns a null-prototype object (created with Object.create(null)) — it does not inherit Object.prototype methods. Available in Node.js 21+, Chrome 117+, Firefox 119+, and Safari 17.4+. For older environments, use a polyfill or the reduce() pattern with Object.create(null) as the initial value. TypeScript support requires lib: ["ES2024"] in tsconfig.json.
Map.groupBy()
A static method introduced alongside Object.groupBy() in ES2024 that groups iterable elements into a Map instead of a plain object. Syntax: Map.groupBy(iterable, keyFn). Unlike Object.groupBy(), keys are not coerced to strings — Map uses SameValueZero equality, so numbers remain numbers, but two distinct object references with the same value are treated as different keys. Preserves key insertion order for all key types (not just strings). Use when grouping by numeric enums, symbols, or other non-string values where type preservation matters. Iterate the result with for...of or map.entries().
lodash _.groupBy()
A lodash utility function that groups a collection by the return value of an iteratee. Syntax: _.groupBy(collection, iteratee). The iteratee can be a string property name (shorthand: {"_.groupBy(arr, 'status')"}) or a callback function. Returns a plain object whose keys are the group values and values are arrays of matching elements — the same shape as Object.groupBy(). Works in Node.js 0.10+ and all modern browsers. Tree-shakeable with lodash-es: import { groupBy } from "lodash-es". Pairs naturally with _.countBy() (count per group), _.mapValues() (transform group arrays), and _.sumBy() / _.meanBy() (aggregation).
group aggregation
The process of computing summary statistics — count, sum, average, min, max — from the arrays produced by a groupBy operation. In JavaScript, group aggregation typically uses Array.reduce() within each group or a single-pass reduce() that accumulates both group membership and aggregates simultaneously. The standard two-step pipeline is: Object.groupBy() to create group arrays, then Object.entries().map() to compute aggregates per group. For large datasets, a single-pass reduce() that accumulates aggregate accumulators alongside the grouping key is more efficient, eliminating the need for a second iteration over each group array.
null-prototype object
A JavaScript object created with Object.create(null) that has no prototype chain — it does not inherit from Object.prototype and therefore lacks methods like hasOwnProperty, toString, valueOf, and __proto__. Object.groupBy() returns a null-prototype object to prevent prototype pollution — a security issue where a group key like "__proto__" or "constructor" would otherwise modify the object's prototype chain. To check for key presence, use Object.hasOwn(grouped, key) (ES2022+) instead of grouped.hasOwnProperty(key) which throws on a null-prototype object. Object.keys(), Object.values(), and Object.entries() all work normally on null-prototype objects.

FAQ

How do I group a JSON array by a property in JavaScript?

Use Array.reduce() with the nullish assignment pattern: array.reduce((acc, item) => { (acc[item.category] ??= []).push(item); return acc; }, {}). The ??= operator initialises an empty array the first time a key is seen and then pushes the item. For ES2024-compatible environments (Node.js 21+, Chrome 117+), use the native Object.groupBy(array, item => item.category) instead — same output, less code. For older environments without either option, lodash {"_.groupBy(array, 'category')"} works everywhere. All three approaches return an object where keys are the distinct group values and values are arrays of matching items. The result is a plain object, not another array — iterate it with Object.entries(grouped).

What is Object.groupBy() in JavaScript?

Object.groupBy() is a static method on Object introduced in the ECMAScript 2024 specification. It takes an iterable (array, Set, generator) and a key function, and returns a plain object where each key maps to an array of items that produced that key. The returned object has a null prototype — it does not inherit Object.prototype — so call Object.hasOwn(result, key) instead of result.hasOwnProperty(key). It is supported natively in Node.js 21+, Chrome 117+, Firefox 119+, and Safari 17.4+. In TypeScript, enable it by adding {""ES2024""} to the lib array in tsconfig.json. For environments that do not support it, a one-line polyfill wraps the reduce() pattern.

What is the difference between Object.groupBy() and Map.groupBy()?

The key difference is the return type and key handling. Object.groupBy() returns a plain object (null-prototype) — all keys are coerced to strings, so new Date() becomes its toString() representation and numeric keys become string "1", "2". Map.groupBy() returns a true Map — keys preserve their original type and identity using SameValueZero equality, so a number 3 remains a number key in the Map. Use Object.groupBy() when keys are strings or symbols and you want a plain object for easy property access (grouped['electronics']). Use Map.groupBy() when keys are non-string types (numbers, Dates as strings derived from the date) and you need to iterate entries in guaranteed insertion order or look up by the original key type.

How do I use lodash to group a JSON array?

Install lodash (npm install lodash) and call {"_.groupBy(array, 'propertyName')"} with a string shorthand or a function: {"_.groupBy(users, 'status')"} groups by the status property. Use a function for computed keys: {"_.groupBy(products, p => p.price > 100 ? 'expensive' : 'cheap')"}. The return value is a plain object — same shape as Object.groupBy(). For bundle-size-conscious projects, import only the function you need: import groupBy from 'lodash/groupBy' (CommonJS) or import { groupBy } from "lodash-es" (ESM, tree-shakeable). Combine with _.mapValues(grouped, items => items.length) to count per group, or _.countBy(array, 'status') for a direct count without creating group arrays.

How do I group by multiple properties?

Two approaches. First, use a composite key — concatenate the properties into a single string key: array.reduce((acc, p) => { const key = `${p.category}__${p.status}`; (acc[key] ??= []).push(p); return acc; }, {}). Split the key later with key.split('__'). Second, use nested grouping — group by the first property, then map each group through another groupBy: Object.fromEntries(Object.entries(Object.groupBy(array, p => p.category)).map(([cat, items]) => [cat, Object.groupBy(items, p => p.status)])). This produces grouped["electronics"]["active"]. For dynamic multi-level grouping at runtime (user-selected dimensions), write a recursive groupByMultiple(array, keysArray) helper that applies Object.groupBy() at each level.

How do I count items in each group?

After grouping, map the grouped object: Object.entries(grouped).map(([key, items]) => ({ key, count: items.length })). For a plain count object without arrays, use reduce() directly: array.reduce((acc, item) => { acc[item.category] = (acc[item.category] ?? 0) + 1; return acc; }, {}) — this is more efficient because it never creates group arrays. With lodash, {"_.countBy(array, 'category')"} does this in one call and returns { "electronics": 3, "clothing": 2 }. With Object.groupBy(), combine with Object.fromEntries(): Object.fromEntries(Object.entries(Object.groupBy(array, x => x.category)).map(([k, v]) => [k, v.length])).

How do I sum values after grouping?

Map the grouped object to sums: Object.fromEntries(Object.entries(grouped).map(([key, items]) => [key, items.reduce((sum, x) => sum + x.price, 0)])). For multiple aggregates at once (count, sum, average), chain them in a single map(): Object.entries(grouped).map(([key, items]) => ({ key, count: items.length, sum: items.reduce((s, x) => s + x.price, 0), avg: items.reduce((s, x) => s + x.price, 0) / items.length })). For large arrays, compute the sum inside the initial grouping reduce() to avoid a second iteration: array.reduce((acc, x) => { const g = (acc[x.cat] ??= { count: 0, sum: 0 }); g.count++; g.sum += x.price; return acc; }, {}). With lodash: {"_.mapValues(_.groupBy(arr, 'cat'), items => _.sumBy(items, 'price'))"}.

How do I group a JSON array in Node.js?

In Node.js 21+, Object.groupBy() and Map.groupBy() are available natively — no import or polyfill needed. Read a JSON file and group: const data = JSON.parse(fs.readFileSync("data.json", "utf8")); const grouped = Object.groupBy(data.items, x => x.category). In Node.js 18-20, use the Array.reduce() pattern or install lodash. For ESM projects ("type": "module" in package.json), import lodash as import { groupBy } from "lodash-es". For streaming large JSON files in Node.js without loading the entire file into memory, use the stream-json package and apply the reduce() groupBy accumulator per record as items stream through the parser.

Further reading and primary sources

  • MDN: Object.groupBy()Official MDN documentation for Object.groupBy() with examples, browser compatibility, and polyfill patterns
  • MDN: Map.groupBy()Official MDN documentation for Map.groupBy() with key type preservation and iteration examples
  • MDN: Array.prototype.reduce()Array.reduce() reference — the universal groupBy building block that works in all environments
  • lodash _.groupBy documentationlodash groupBy API reference with string shorthand and function iteratee examples
  • TC39 Proposal: Array GroupingThe original TC39 proposal that introduced Object.groupBy() and Map.groupBy() into the ECMAScript standard