receta
Collection

Migration Guide

From Manual Collection Operations

Step-by-step guide to refactoring manual loops, grouping, and change detection to use Collection module patterns.

Why Migrate?

Before: Manual Collection Operations

// โŒ Problems:
// Manual hierarchical grouping
const commentsByUserAndPost: Record<number, Record<number, Comment[]>> = {}
for (const comment of comments) {
  if (!commentsByUserAndPost[comment.userId]) {
    commentsByUserAndPost[comment.userId] = {}
  }
  if (!commentsByUserAndPost[comment.userId][comment.postId]) {
    commentsByUserAndPost[comment.userId][comment.postId] = []
  }
  commentsByUserAndPost[comment.userId][comment.postId].push(comment)
}

// Manual change detection
const added = newUsers.filter(nu => !oldUsers.some(ou => ou.id === nu.id))
const removed = oldUsers.filter(ou => !newUsers.some(nu => nu.id === ou.id))
const updated = newUsers.filter(nu => {
  const old = oldUsers.find(ou => ou.id === nu.id)
  return old && JSON.stringify(old) !== JSON.stringify(nu)
})

// Manual pagination
const start = (page - 1) * pageSize
const items = allItems.slice(start, start + pageSize)
const hasMore = start + pageSize < allItems.length

Issues:

  • ๐Ÿ”ด Verbose: 30+ lines for common operations
  • ๐Ÿ”ด Error-prone: Easy to get nesting/indexing wrong
  • ๐Ÿ”ด Not reusable: Logic buried in larger functions
  • ๐Ÿ”ด Hard to test: Complex logic mixed with business code
  • ๐Ÿ”ด Silent bugs: Duplicate keys overwrite, off-by-one errors

After: Collection Module

// โœ… Benefits:
import { nest, diff, paginate, indexByUnique } from '@ontologyio/receta/collection'

// Hierarchical grouping - one line
const commentsByUserAndPost = nest(comments, ['userId', 'postId'])

// Change detection - explicit and complete
const changes = diff(oldUsers, newUsers, u => u.id)
// => { added: [...], removed: [...], updated: [{old, new}], unchanged: [...] }

// Pagination - consistent metadata in one call
const result = paginate(allItems, { page: 1, pageSize: 20 })
// => { items: [...], page, pageSize, total, hasNext, hasPrevious }

// Safe indexing - errors on duplicates
const usersById = indexByUnique(users, u => u.id)
// => Ok({...}) or Err(DuplicateKeyError)

Wins:

  • โœ… Concise: 1-2 lines vs 30+
  • โœ… Type-safe: Proper TypeScript inference
  • โœ… Error handling: Explicit Result types
  • โœ… Testable: Pure functions
  • โœ… Discoverable: All patterns in one module

Migration Strategy

Phase 1: Identify Patterns

Look for:

  1. Nested loops creating hierarchical structures
  2. Change detection logic (comparing old/new datasets)
  3. Pagination logic (slicing, page calculations)
  4. Index building (Record<string, T> from arrays)
  5. Set operations on object arrays

Phase 2: Replace Simple Operations

Start with single-use manual operations.

Phase 3: Extract Complex Patterns

Handle multi-level grouping and custom comparisons.

Phase 4: Add Error Handling

Leverage Result types for safer code.


Step-by-Step Examples

Example 1: Single-Level Grouping

Before

// Manual grouping by category
const productsByCategory: Record<string, Product[]> = {}
for (const product of products) {
  if (!productsByCategory[product.category]) {
    productsByCategory[product.category] = []
  }
  productsByCategory[product.category].push(product)
}

Step 1: Identify Pattern

  • Pattern: Group by single property
  • Type: Flat grouping

Step 2: Use Remeda (Not Collection)

import * as R from 'remeda'

// โœ… For single-level grouping, use Remeda
const productsByCategory = R.groupBy(products, p => p.category)

Rule: Use Collection's nest() only for multi-level hierarchies.


Example 2: Multi-Level Grouping

Before

// Group events by repository, then by type
const eventsByRepoAndType: Record<string, Record<string, RepoEvent[]>> = {}
for (const event of events) {
  if (!eventsByRepoAndType[event.repo]) {
    eventsByRepoAndType[event.repo] = {}
  }
  if (!eventsByRepoAndType[event.repo][event.type]) {
    eventsByRepoAndType[event.repo][event.type] = []
  }
  eventsByRepoAndType[event.repo][event.type].push(event)
}

Step 1: Identify Pattern

  • Pattern: Two-level nesting
  • Type: Hierarchical grouping

Step 2: Use nest()

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

const eventsByRepoAndType = nest(events, ['repo', 'type'])
// => { repo_foo: { push: [...], pr: [...] }, repo_bar: {...} }

Advanced: Group by Derived Value

// Group by repo, then by date
const eventsByRepoAndDate = nest<RepoEvent>(events, [
  'repo',
  e => e.timestamp.slice(0, 10) // Extract date
])
// => { repo_foo: { '2024-01-01': [...], '2024-01-02': [...] } }

Example 3: Change Detection

Before

// Detect added/removed/updated users
const added = newUsers.filter(nu =>
  !oldUsers.some(ou => ou.id === nu.id)
)

const removed = oldUsers.filter(ou =>
  !newUsers.some(nu => nu.id === ou.id)
)

const updated = newUsers.filter(nu => {
  const old = oldUsers.find(ou => ou.id === nu.id)
  return old && JSON.stringify(old) !== JSON.stringify(nu)
}).map(nu => ({
  old: oldUsers.find(ou => ou.id === nu.id)!,
  new: nu
}))

const unchanged = newUsers.filter(nu => {
  const old = oldUsers.find(ou => ou.id === nu.id)
  return old && JSON.stringify(old) === JSON.stringify(nu)
})

Problems:

  • O(nยฒ) complexity (nested some/find)
  • JSON.stringify for comparison (depends on key order)
  • Repeated logic across 4 separate filters

Step 1: Use diff()

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

const changes = diff(oldUsers, newUsers, u => u.id)
// => {
//   added: User[],
//   removed: User[],
//   updated: { old: User, new: User }[],
//   unchanged: User[]
// }

Wins:

  • O(n) complexity
  • Proper deep equality
  • All categories in one call

Step 2: Handle Changes

// Send notifications for changes
for (const user of changes.added) {
  sendEmail(user.email, 'Welcome!')
}

for (const { old, new: updated } of changes.updated) {
  if (old.status !== updated.status) {
    sendEmail(updated.email, `Status changed to ${updated.status}`)
  }
}

for (const user of changes.removed) {
  archiveUser(user.id)
}

Advanced: Custom Equality

// Ignore 'lastSeen' field when comparing
const changes = diff(
  oldUsers,
  newUsers,
  u => u.id,
  (a: User, b: User) => R.isDeepEqual(R.omit(a, ['lastSeen']), R.omit(b, ['lastSeen']))
)

Example 4: Offset Pagination

Before

// Manual pagination
const page = Number(req.query.page) || 1
const pageSize = 20
const start = (page - 1) * pageSize
const end = start + pageSize

const items = allItems.slice(start, end)
const total = allItems.length
const totalPages = Math.ceil(total / pageSize)
const hasNext = page < totalPages
const hasPrevious = page > 1

res.json({ items, page, pageSize, total, totalPages, hasNext, hasPrevious })

Problems:

  • Off-by-one errors (start/end calculations)
  • Edge cases (page 0, negative, beyond total)
  • Repeated logic across endpoints

Step 1: Use paginate()

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

const result = paginate(allItems, {
  page: Number(req.query.page) || 1,
  pageSize: 20
})

res.json(result)
// => {
//   items: [...],
//   page: 1,
//   pageSize: 20,
//   total: 100,
//   hasNext: true,
//   hasPrevious: false
// }

There's no totalPages field. Compute it with Math.ceil(result.total / result.pageSize) if you need it.

Wins:

  • No start/end arithmetic to get wrong
  • Empty arrays and pages past the end โ†’ empty items
  • Consistent metadata format

Example 5: Cursor Pagination

Before

// Manual cursor-based pagination
app.get('/api/events', (req, res) => {
  const cursorValue = req.query.cursor
  const limit = Number(req.query.limit) || 50

  let items: RepoEvent[]
  if (cursorValue) {
    const cursorIndex = events.findIndex(e => e.id === cursorValue)
    if (cursorIndex === -1) {
      return res.status(400).json({ error: 'Invalid cursor' })
    }
    items = events.slice(cursorIndex + 1, cursorIndex + 1 + limit)
  } else {
    items = events.slice(0, limit)
  }

  const hasMore = items.length === limit
  const nextCursor = hasMore ? items[items.length - 1].id : null

  res.json({ items, nextCursor, hasMore })
})

Problems:

  • Manual index finding
  • Edge cases (invalid cursor, end of list)
  • Inconsistent error handling

Step 1: Use paginateCursor()

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

const result = paginateCursor(
  events,
  e => e.id,
  {
    cursor: req.query.cursor as string | undefined,
    limit: Number(req.query.limit) || 50
  }
)

res.json(result)
// => {
//   items: RepoEvent[],
//   nextCursor: string | undefined,
//   hasMore: boolean
// }

Wins:

  • Handles missing/invalid cursor โ†’ starts from beginning
  • Proper boundary handling
  • Type-safe cursor extraction

Example 6: Index Building

Before

// Manual indexing - silently overwrites duplicates
const usersById: Record<string, User> = {}
for (const user of users) {
  usersById[user.id] = user // Lost data if duplicate!
}

// Manual deduplication
const seen = new Set<string>()
const uniqueUsers: User[] = []
for (const user of users) {
  if (!seen.has(user.id)) {
    seen.add(user.id)
    uniqueUsers.push(user)
  }
}

Problems:

  • Duplicates silently overwrite โ†’ data loss
  • No error on collision
  • Manual deduplication is verbose

Step 1: Use indexByUnique()

import { indexByUnique } from '@ontologyio/receta/collection'
import { unwrapOr, isOk } from '@ontologyio/receta/result'

// โœ… Error on duplicates
const result = indexByUnique(users, u => u.id)

if (isOk(result)) {
  const usersById = result.value
} else {
  console.error(`Duplicate user ID: ${result.error.key}`)
}

Step 2: Choose Collision Strategy

// Keep first occurrence
const byId = indexByUnique(users, u => u.id, { onCollision: 'first' })

// Keep last occurrence
const byId = indexByUnique(users, u => u.id, { onCollision: 'last' })

// Error on duplicates (default)
const byId = indexByUnique(users, u => u.id, { onCollision: 'error' })

Use Cases:

  • 'error': Critical data (user IDs, order IDs)
  • 'first': Preserve original
  • 'last': Override with latest

Example 7: Set Operations

Before

// Manual union - creates duplicates!
const allUsers = [...activeUsers, ...pendingUsers]

// Manual deduplication
const uniqueUsers = allUsers.filter((user, index, self) =>
  self.findIndex(u => u.id === user.id) === index
)

// Manual intersection
const both = activeUsers.filter(au =>
  pendingUsers.some(pu => pu.id === au.id)
)

// Manual difference
const onlyActive = activeUsers.filter(au =>
  !pendingUsers.some(pu => pu.id === au.id)
)

Problems:

  • Repeated custom equality logic
  • Not reusable

Step 1: Use Set Operations

Receta has union, intersect and symmetricDiff. For "in the first list but not the second", use Remeda's differenceWith.

import { union, intersect, symmetricDiff } from '@ontologyio/receta/collection'
import * as R from 'remeda'

const byId = (a: User, b: User) => a.id === b.id

// All active users, plus the pending users not already among them
const allUsers = union(activeUsers, pendingUsers, byId)

// Users in both lists
const both = intersect(activeUsers, pendingUsers, byId)

// Only in active
const onlyActive = R.differenceWith(activeUsers, pendingUsers, byId)

// In one list but not both
const exclusive = symmetricDiff(activeUsers, pendingUsers, byId)

These compare every pair of items, so they stay O(n ร— m). The win is clarity, not speed.

Wins:

  • Reusable equality function
  • Clear intent

Example 8: Nested Property Grouping

Before

// Group orders by customer country
const ordersByCountry: Record<string, Order[]> = {}
for (const order of orders) {
  const country = order.customer.address.country
  if (!ordersByCountry[country]) {
    ordersByCountry[country] = []
  }
  ordersByCountry[country].push(order)
}

Step 1: Use groupByPath()

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

const ordersByCountry = groupByPath(orders, 'customer.address.country')
// => { US: [...], CA: [...], UK: [...] }

Advanced: Multi-Level Nested Path

// Group by country, then by payment status
const ordersByCountryAndStatus = pipe(
  orders,
  nest([
    o => o.customer.address.country,
    o => o.payment.status
  ])
)
// => { US: { paid: [...], pending: [...] }, CA: {...} }

Incremental Migration

You don't need to refactor everything at once!

Strategy 1: Start with Most Repeated

Find the most duplicated manual logic and replace it first.

// 1. Find repeated pattern
// โŒ Manual grouping everywhere:
const manualGroups: Record<string, Item[]> = {}
for (const item of items) {
  if (!manualGroups[item.category]) manualGroups[item.category] = []
  manualGroups[item.category].push(item)
}

// 2. Replace with nest() or R.groupBy()
const grouped = R.groupBy(items, i => i.category)

// 3. Gradually replace call sites

Strategy 2: Create Collection Utilities Module

// utils/collections.ts
import { nest, diff, paginate } from '@ontologyio/receta/collection'

export const groupEventsByRepoAndType = (events: RepoEvent[]) =>
  nest(events, ['repo', 'type'])

export const detectUserChanges = (old: User[], new_: User[]) =>
  diff(old, new_, u => u.id)

export const paginateResults = <T>(items: T[], page: number) =>
  paginate(items, { page, pageSize: 20 })
// Usage
import * as Collections from './utils/collections'

const grouped = Collections.groupEventsByRepoAndType(events)
const changes = Collections.detectUserChanges(oldUsers, newUsers)

Strategy 3: Coexist During Migration

Old and new can live side-by-side:

// โœ… Keep existing code working
const manual = oldGroupingLogic(items)

// โœ… New code uses Collection module
const grouped = nest(items, ['category', 'priority'])

Common Pitfalls

Pitfall 1: Using nest() for Single-Level Grouping

// โŒ Overkill
const grouped = nest(items, ['category'])

// โœ… Use Remeda's groupBy
const grouped = R.groupBy(items, i => i.category)

Rule: Use nest() only for 2+ levels.

Pitfall 2: JSON.stringify for Equality

// โŒ Unreliable (key order, functions, etc.)
diff(old, new_, u => u.id, (a, b) => JSON.stringify(a) === JSON.stringify(b))

// โœ… Leave it out: the default is Remeda's isDeepEqual
diff(old, new_, u => u.id)

Pitfall 3: Ignoring Result Types

// โŒ Assumes success: TypeScript rejects this, and it's undefined on Err
const byId = indexByUnique(users, u => u.id)
console.log(byId.value)
// โœ… Handle Result
const byId = indexByUnique(users, u => u.id)
if (isOk(byId)) {
  console.log(byId.value)
} else {
  console.error('Duplicate key:', byId.error.key)
}

// Or unwrap with default
const byId = unwrapOr(indexByUnique(users, (u: User) => u.id), {})

Pitfall 4: Off-by-One in Page Numbers

// โŒ paginate() doesn't validate the page. Page 0 or NaN returns the first
// items, but the result still says page: 0 or page: NaN.
const page = Number(req.query.page)

// โœ… Clamp it before you call paginate()
const safePage = Math.max(1, Number(req.query.page) || 1)
paginate(items, { page: safePage, pageSize: 10 })

Testing After Migration

Before (Hard to Test)

// Can't test grouping logic in isolation
const getEventsByRepo = (events: RepoEvent[]) => {
  const grouped: Record<string, Record<string, RepoEvent[]>> = {}
  for (const event of events) {
    // 20 lines of manual logic...
  }
  return grouped
}

After (Easy to Test)

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

const getEventsByRepo = (events: RepoEvent[]) =>
  nest(events, ['repo', 'type'])

describe('Event grouping', () => {
  it('groups events by repo and type', () => {
    const events = [
      { repo: 'foo', type: 'push', id: 1 },
      { repo: 'foo', type: 'pr', id: 2 },
      { repo: 'bar', type: 'push', id: 3 }
    ]

    const result = getEventsByRepo(events)

    expect(result).toEqual({
      foo: { push: [events[0]], pr: [events[1]] },
      bar: { push: [events[2]] }
    })
  })
})

Checklist

Use this checklist when migrating code:

  • Identify manual grouping loops (nested for loops)
  • Replace multi-level grouping with nest()
  • Replace single-level grouping with Remeda's groupBy()
  • Replace change detection with diff()
  • Replace manual pagination with paginate() or paginateCursor()
  • Replace manual indexing with indexByUnique()
  • Replace manual set operations with union(), intersect(), etc.
  • Add Result type handling (isOk, unwrapOr)
  • Write tests for migrated functions
  • Update call sites to use new functions
  • Remove old manual implementations
  • Document edge cases and collision strategies

Migration Examples by Domain

Social Media Platform

// Before: Manual comment grouping
const byUserAndPost: Record<number, Record<number, Comment[]>> = {}
for (const comment of comments) {
  if (!byUserAndPost[comment.userId]) byUserAndPost[comment.userId] = {}
  if (!byUserAndPost[comment.userId][comment.postId]) {
    byUserAndPost[comment.userId][comment.postId] = []
  }
  byUserAndPost[comment.userId][comment.postId].push(comment)
}

// After: nest()
const byUserAndPost = nest(comments, ['userId', 'postId'])

E-Commerce Order System

// Before: Manual change detection
const newOrders = fetchOrders()
const statusChanges = newOrders.filter(no => {
  const old = oldOrders.find(oo => oo.id === no.id)
  return old && old.status !== no.status
})

// After: diff()
const changes = diff(oldOrders, newOrders, o => o.id)
for (const { old, new: updated } of changes.updated) {
  if (old.status !== updated.status) {
    notifyStatusChange(updated)
  }
}

API Endpoint Pagination

// Before: Manual offset pagination
app.get('/api/products', (req, res) => {
  const page = Number(req.query.page) || 1
  const pageSize = 20
  const start = (page - 1) * pageSize
  const items = products.slice(start, start + pageSize)
  res.json({ items, page, hasMore: start + pageSize < products.length })
})

// After: paginate()
app.get('/api/products', (req, res) => {
  const result = paginate(products, {
    page: Number(req.query.page) || 1,
    pageSize: 20
  })
  res.json(result)
})

Performance Considerations

Before Migration: O(nยฒ)

// โŒ Nested loops for change detection
const added = newUsers.filter(nu =>
  !oldUsers.some(ou => ou.id === nu.id) // O(n) for each item
)
// Total: O(nยฒ)

After Migration: O(n)

// โœ… Single pass with Map
const changes = diff(oldUsers, newUsers, u => u.id)
// Total: O(n)

diff() indexes each side once, so it stays linear. union(), intersect() and symmetricDiff() compare every pair, so they're no faster than the manual versions.


Next Steps

On this page