receta
Collection

Hierarchical Grouping

Deep dive into nest() and groupByPath() for organizing data hierarchically.

Overview

Hierarchical grouping creates multi-level nested structures from flat arrays. Essential for:

  • Comment threads (user → post → replies)
  • Analytics dashboards (region → category → metrics)
  • File systems (folder → subfolder → files)
  • Organization charts (department → team → members)

nest() - Multi-Level Grouping

Groups items by multiple keys sequentially, creating nested maps.

Basic Usage

import { nest } from '@ontologyio/receta/collection'

const comments = [
  { userId: 1, postId: 10, text: 'Great!' },
  { userId: 1, postId: 10, text: 'Thanks!' },
  { userId: 1, postId: 20, text: 'Nice' },
  { userId: 2, postId: 10, text: 'Cool' }
]

// Group by userId, then by postId
const nested = nest(comments, ['userId', 'postId'])

// Result:
// {
//   1: {
//     10: [{ userId: 1, postId: 10, text: 'Great!' }, { userId: 1, postId: 10, text: 'Thanks!' }],
//     20: [{ userId: 1, postId: 20, text: 'Nice' }]
//   },
//   2: {
//     10: [{ userId: 2, postId: 10, text: 'Cool' }]
//   }
// }

With Function Selectors

Use functions for dynamic grouping:

const users = [
  { name: 'Alice', age: 25, country: 'US' },
  { name: 'Bob', age: 35, country: 'US' },
  { name: 'Charlie', age: 25, country: 'UK' }
]

// Group by age range, then by country
const grouped = nest(users, [
  (u) => u.age >= 30 ? 'senior' : 'junior',
  'country'
])

// Result:
// {
//   junior: {
//     US: [{ name: 'Alice', age: 25, country: 'US' }],
//     UK: [{ name: 'Charlie', age: 25, country: 'UK' }]
//   },
//   senior: {
//     US: [{ name: 'Bob', age: 35, country: 'US' }]
//   }
// }

Real-World: GitHub Event Feed

interface GitHubEvent {
  id: string
  repo: string
  type: 'push' | 'pr' | 'issue'
  user: string
  timestamp: string
}

const events: GitHubEvent[] = [
  { id: '1', repo: 'receta', type: 'push', user: 'alice', timestamp: '2024-01-01' },
  { id: '2', repo: 'receta', type: 'pr', user: 'bob', timestamp: '2024-01-02' },
  { id: '3', repo: 'remeda', type: 'push', user: 'alice', timestamp: '2024-01-03' }
]

// Group by repo → type → user for activity dashboard
const activityFeed = nest(events, ['repo', 'type', 'user'])

// Shape: { receta: { push: { alice: [event1] }, pr: { bob: [event2] } }, remeda: { push: { alice: [event3] } } }

Real-World: E-commerce Categories

interface Product {
  id: string
  category: string
  subcategory: string
  brand: string
  price: number
}

const products: Product[] = [
  { id: '1', category: 'electronics', subcategory: 'laptops', brand: 'apple', price: 1299 },
  { id: '2', category: 'electronics', subcategory: 'laptops', brand: 'dell', price: 899 },
  { id: '3', category: 'electronics', subcategory: 'phones', brand: 'apple', price: 999 }
]

// Build product catalog hierarchy
const catalog = nest(products, ['category', 'subcategory', 'brand'])

// Shape: { electronics: { laptops: { apple: [product1], dell: [product2] }, phones: { apple: [product3] } } }

Data-Last in Pipe

import { pipe } from 'remeda'
import * as R from 'remeda'

const activeUserComments = pipe(
  comments,
  R.filter(c => c.active),
  nest(['userId', 'postId'])
)

groupByPath() - Group by Nested Properties

Groups items by values at nested object paths.

Basic Usage

import { groupByPath } from '@ontologyio/receta/collection'

const users = [
  { name: 'Alice', profile: { role: 'admin' } },
  { name: 'Bob', profile: { role: 'user' } },
  { name: 'Charlie', profile: { role: 'admin' } }
]

// Group by nested path
const byRole = groupByPath(users, 'profile.role')

// Result:
// {
//   admin: [
//     { name: 'Alice', profile: { role: 'admin' } },
//     { name: 'Charlie', profile: { role: 'admin' } }
//   ],
//   user: [{ name: 'Bob', profile: { role: 'user' } }]
// }

Real-World: Stripe Charges by Customer Email

interface Charge {
  id: string
  amount: number
  customer: {
    id: string
    email: string
  }
  status: 'succeeded' | 'pending' | 'failed'
}

const charges: Charge[] = [
  { id: 'ch_1', amount: 1000, customer: { id: 'cus_1', email: 'alice@example.com' }, status: 'succeeded' },
  { id: 'ch_2', amount: 500, customer: { id: 'cus_1', email: 'alice@example.com' }, status: 'succeeded' },
  { id: 'ch_3', amount: 2000, customer: { id: 'cus_2', email: 'bob@example.com' }, status: 'pending' }
]

// Group charges by customer email
const chargesByCustomer = groupByPath(charges, 'customer.email')

// Calculate total per customer
Object.entries(chargesByCustomer).map(([email, charges]) => ({
  email,
  total: charges.reduce((sum, c) => sum + c.amount, 0)
}))

Real-World: Order Status Dashboard

interface Order {
  id: string
  items: string[]
  payment: {
    method: string
    status: 'paid' | 'pending' | 'failed'
  }
  shipping: {
    status: 'delivered' | 'in_transit' | 'pending'
  }
}

const orders: Order[] = [
  { id: '1', items: ['laptop'], payment: { method: 'card', status: 'paid' }, shipping: { status: 'delivered' } },
  { id: '2', items: ['mouse'], payment: { method: 'paypal', status: 'pending' }, shipping: { status: 'pending' } }
]

// Group by payment status
const byPaymentStatus = groupByPath(orders, 'payment.status')

// Group by shipping status
const byShippingStatus = groupByPath(orders, 'shipping.status')

Handling Missing Paths

const items = [
  { id: 1, metadata: { tags: ['urgent'] } },
  { id: 2, metadata: null }, // null metadata
  { id: 3 } // missing metadata
]

const grouped = groupByPath(items, 'metadata.tags')

// Values become keys through String(), so ['urgent'] is 'urgent'
// and a missing path is 'undefined'
// Result:
// {
//   urgent: [{ id: 1, metadata: { tags: ['urgent'] } }],
//   undefined: [{ id: 2, metadata: null }, { id: 3 }]
// }

Combining Both Functions

Use groupByPath() for the nested property, then nest() for the levels under it:

const events = [
  { id: '1', user: { role: 'admin' }, repo: 'foo', type: 'push' },
  { id: '2', user: { role: 'user' }, repo: 'foo', type: 'pr' },
  { id: '3', user: { role: 'admin' }, repo: 'bar', type: 'push' }
]

// First group by user role
const byRole = groupByPath(events, 'user.role')

// Then group each role by repo and type
const withRepos = R.mapValues(byRole, (group) => nest(group, ['repo', 'type']))

// Result:
// {
//   admin: {
//     foo: { push: [event1] },
//     bar: { push: [event3] }
//   },
//   user: {
//     foo: { pr: [event2] }
//   }
// }

When to Use Which?

Use CaseFunctionExample
Multiple top-level keysnest()Group by [category, priority, status]
Nested object propertygroupByPath()Group by 'user.role'
Dynamic groupingnest() with functionsGroup by age ranges
Single nested pathgroupByPath()Group by 'metadata.category'
Deep hierarchy (3+ levels)nest()Comments by [user, post, parent]

Performance Considerations

Time Complexity

  • nest(): O(n × k) where n = items, k = number of keys
  • groupByPath(): O(n) single pass through items

Optimization Tips

import { getPath } from '@ontologyio/receta/object'
import { unwrapOr } from '@ontologyio/receta/option'

// ❌ Avoid: Re-grouping on every access
function getComments(userId: number, postId: number) {
  const grouped = nest(comments, ['userId', 'postId'])
  return unwrapOr(getPath<Comment[]>([userId, postId], grouped), [])
}

// ✅ Better: Group once, reuse
const commentsByUserAndPost = nest(comments, ['userId', 'postId'])

function getComments(userId: number, postId: number) {
  return unwrapOr(getPath<Comment[]>([userId, postId], commentsByUserAndPost), [])
}

Common Patterns

Pattern 1: Fallback to Empty Array

Each level of a NestedMap is a union, so read it with getPath rather than indexing:

const comments = unwrapOr(getPath<Comment[]>([userId, postId], commentsByUserAndPost), [])

Pattern 2: Nested Access with Default

const getNestedValue = <T>(
  nested: NestedMap<T>,
  keys: (string | number)[]
): T[] => unwrapOr(getPath<T[]>(keys, nested), [])

const comments = getNestedValue(grouped, [1, 10])

Pattern 3: Transform After Grouping

import { mapValues } from '@ontologyio/receta/object'

// Group, then aggregate
const avgPriceByCategory = pipe(
  products,
  nest(['category', 'subcategory']),
  mapValues(bySubcat =>
    mapValues(bySubcat, items => ({
      count: items.length,
      avgPrice: items.reduce((sum, p) => sum + p.price, 0) / items.length
    }))
  )
)

TypeScript Tips

Type Safety

// nest returns NestedMap<Item>, which doesn't track how deep it goes
const grouped = nest(items, ['category', 'priority'])

// So every level is a union: Item | NestedMap<Item> | readonly Item[]
const electronics = grouped.electronics

Custom Type Guards

function isItemArray<T>(value: T | NestedMap<T> | readonly T[]): value is readonly T[] {
  return Array.isArray(value)
}

const value = grouped.electronics
if (isItemArray(value)) {
  // value is readonly T[]
  value.forEach(item => console.log(item))
}

Testing

import { describe, it, expect } from 'vitest'
import { nest, groupByPath } from '@ontologyio/receta/collection'

describe('Grouping operations', () => {
  it('creates nested structure', () => {
    const result = nest(items, ['cat', 'pri'])
    expect(result).toHaveProperty('electronics.high')
  })

  it('groups by nested path', () => {
    const result = groupByPath(users, 'profile.role')
    expect(result.admin).toBeDefined()
    expect(result.admin[0].profile.role).toBe('admin')
  })
})

Next Steps

On this page