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.lengthIssues:
- ๐ด 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:
- Nested loops creating hierarchical structures
- Change detection logic (comparing old/new datasets)
- Pagination logic (slicing, page calculations)
- Index building (
Record<string, T>from arrays) - 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 sitesStrategy 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
forloops) - Replace multi-level grouping with
nest() - Replace single-level grouping with Remeda's
groupBy() - Replace change detection with
diff() - Replace manual pagination with
paginate()orpaginateCursor() - 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
- API Reference - Complete function reference
- Common Patterns - Real-world examples
- README - Documentation overview
Common Patterns & Production Recipes
Real-world patterns from GitHub, Reddit, Stripe, and more.
Overview
The Object module provides utilities for safely manipulating nested objects with type-safe access, validation, and transformation. Unlike vanilla JavaScript or Lodash, all operations are immutable, integrate with Result/Option patterns, and work seamlessly in pipelines.