# Introduction Receta makes failure, absence and flow visible in your types. Receta is a TypeScript library of recipes built on [Remeda](https://remedajs.com). Remeda gives you `map` and `pipe`. Receta gives you what real code needs on top of them: errors as values, absence as a type, retries with limits, and validation that reports every problem at once. The point isn't style. When a failure is in the signature, a person can check code they didn't write by reading it, and that includes code an agent wrote. ## A first pipe This function can fail in two ways, and both are in its return type. Nothing throws behind the reader's back. ```ts import * as R from 'remeda' import { err, flatMap, map, ok, parseJSON, type Result } from '@ontologyio/receta/result' type Order = { readonly id: string; readonly items: readonly string[] } const requireItems = (order: Order): Result => order.items.length > 0 ? ok(order) : err('EMPTY_ORDER') const countItems = (raw: string) => R.pipe( parseJSON(raw), flatMap(requireItems), map((order) => order.items.length), ) countItems('{"id":"a1","items":["tea"]}') // => Ok(1) countItems('{"id":"a2","items":[]}') // => Err('EMPTY_ORDER') ``` Read it top to bottom. Each line is one step, and the happy path is the only path on the page. If `parseJSON` or `requireItems` fails, the steps after it don't run and the failure comes out the end as a value. ## Where to go next | You want to | Read | | --- | --- | | Install it and see how imports work | [Installation](./installation.mdx) | | Find the right tool for a problem | [Choose a module](./choose-a-module.mdx) | | Stop throwing exceptions | [Result](./result/index.mdx) | | Stop passing `null` around | [Option](./option/index.mdx) | | Retry, limit and time out async work | [Async](./async/index.mdx) | | Report every form error at once | [Validation](./validation/index.mdx) | | Look up an exact signature | [Reference](./reference/result.mdx) | | Point an agent at these docs | [Use with agents](./agents.mdx) | | Compare Receta with neverthrow | [Receta vs neverthrow](./neverthrow-alternative.mdx) | ## Every example here runs The examples in these docs are compiled against the published package on every change, and each `// => value` comment is checked against what the code really returns. If an example is wrong, the build fails. A few blocks show deliberately broken code, and those are the only ones left out. --- # Use with agents Plain-text docs for coding agents, plus skills and lint rules that keep their output honest. Receta was built for code that a person has to verify and an agent may have written. These docs are published in forms an agent can read directly. ## Docs as plain text | Address | What it holds | | --- | --- | | /llms.txt | An index of every page, one line each | | /llms-full.txt | All the docs in one file | | `/docs/result.md` | Any page as markdown: add `.md` to its address | Tools that ask for markdown get it from the normal address too: ```bash curl -H 'Accept: text/markdown' https://receta.dev/docs/result ``` Point an agent at `/llms.txt` when it should find its own way, and at `/llms-full.txt` when it should read everything first. ## Skills for Claude Code The `receta-skills` plugin teaches Claude Code the patterns. It can explain a concept, refactor vanilla code, review code for Receta patterns, migrate a codebase, scaffold a module and check readability. ```text /plugin marketplace add ontology-io/receta /plugin install receta-skills@receta-tools ``` ## Lint rules `@ontologyio/eslint-plugin-receta` catches try/catch, null returns and array method chains in code that was meant to use Receta, so an agent gets the same feedback a reviewer would give. ```bash bun add -d @ontologyio/eslint-plugin-receta ``` --- # Overview Async utilities provide battle-tested patterns for handling concurrent operations, rate limits, retries, and timeoutsβ€”making your async code reliable without manual Promise juggling. ## The Problem: Promises Are Low-Level Primitives ### Real-World Example: Stripe API Rate Limiting ```typescript // Traditional approach - overwhelming the API async function processRefunds(chargeIds: string[]) { const promises = chargeIds.map(id => stripe.refunds.create({ charge: id }) ) // πŸ’₯ Fires 1000 requests simultaneously! const results = await Promise.all(promises) return results } // What goes wrong? πŸ€” // - Stripe rate limit: 100 req/sec β†’ 429 Too Many Requests // - No retry logic β†’ manual rerun needed // - No timeout β†’ hangs forever on slow networks // - All-or-nothing β†’ one failure kills everything // - Memory spike β†’ loading 1000 responses at once ``` When you call `processRefunds()` with 1000 charge IDs: - **429 errors** flood in after request #100 - Your **Lambda times out** waiting for responses - **Half the refunds succeed**, half fail (inconsistent state) - You have **no idea which ones** worked - You'll only discover this when customers complain ### How Successful Products Handle This **Stripe's Best Practices** (from their docs): ```text // They recommend: // - Rate limiting (100 req/sec) // - Exponential backoff on 429 // - Idempotency keys for retries // - Batch operations where possible ``` **GitHub API** (actual rate limit headers): ``` X-RateLimit-Limit: 5000 X-RateLimit-Remaining: 4999 X-RateLimit-Reset: 1372700873 Retry-After: 60 ``` **Vercel Deployments** (actual deployment pattern): ```text // Poll deployment status every 2 seconds // Timeout after 5 minutes // Retry on network errors // Cancel if user navigates away ``` **AWS Lambda Best Practices**: ```text // Concurrency limits per region // Timeout configuration (max 15 minutes) // Retry with exponential backoff // Dead letter queues for failures ``` **What they have in common**: **Controlled concurrency**, **automatic retries**, **timeout protection**, and **graceful degradation**. ## The Solution: Async Utilities ```typescript import { mapAsync, retry, timeout } from '@ontologyio/receta/async' type StripeError = { statusCode?: number } async function processRefunds(chargeIds: string[]) { return mapAsync( chargeIds, (id) => timeout( // Retry on rate limits retry(() => stripe.refunds.create({ charge: id }), { maxAttempts: 3, delay: 1000, backoff: 2, shouldRetry: (error) => (error as StripeError).statusCode === 429 }), 30000 // 30 second limit per refund, retries included ), { concurrency: 10 } // Max 10 concurrent requests ) } ``` **Now your code:** 1. βœ… Never overwhelms Stripe's API (10 concurrent max) 2. βœ… Automatically retries rate limits (exponential backoff) 3. βœ… Stops waiting on stuck requests (30s per refund) 4. βœ… Continues on individual failures (each refund gets its own Result) 5. βœ… Returns both successes and failures (full visibility) ## Why Async Utilities Over Manual Promise Handling? ### Problem 1: Manual Concurrency Control is Complex ```typescript // Traditional manual concurrency limiting async function processWithLimit( items: T[], fn: (item: T) => Promise, limit: number ): Promise { const results: U[] = [] const executing = new Set>() for (const [index, item] of items.entries()) { const promise = fn(item).then(result => { results[index] = result executing.delete(promise) }) executing.add(promise) if (executing.size >= limit) { await Promise.race(executing) } } await Promise.all(executing) return results } // 50 lines of boilerplate for EVERY async operation! ``` **With mapAsync:** ```typescript const results = await mapAsync( items, fn, { concurrency: 10 } ) // 1 line. Done. ``` ### Problem 2: Retry Logic is Error-Prone ```typescript // Traditional retry with exponential backoff async function retryWithBackoff( fn: () => Promise, maxAttempts: number = 3 ): Promise { let lastError: unknown for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn() } catch (error) { lastError = error if (attempt < maxAttempts - 1) { const delay = Math.min(1000 * Math.pow(2, attempt), 10000) await new Promise(resolve => setTimeout(resolve, delay)) } } } throw lastError } // Complex, easy to get wrong, hard to customize ``` **With retry:** ```typescript const result = await retry( fn, { maxAttempts: 3, delay: 1000, backoff: 2, maxDelay: 10000 } ) // Handles all edge cases, fully customizable ``` ### Problem 3: Timeouts Take a Race and a Cleanup ```typescript // Traditional timeout: a race, a timer, and cleanup in every project async function withTimeout( promise: Promise, ms: number ): Promise { let timeoutId: ReturnType | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`Timeout after ${ms}ms`)) }, ms) }) try { return await Promise.race([promise, timeoutPromise]) } finally { clearTimeout(timeoutId) } } // Problem: A timeout is one more exception to catch // and tell apart from every other failure ``` **With timeout:** ```typescript const controller = new AbortController() const result = await timeout(fetch('/api/report', { signal: controller.signal }), 5000) // timeout stops waiting, but a promise can't be cancelled from outside // To stop the work too, pass it a signal and abort if (isErr(result)) controller.abort() ``` ## Real-World Use Cases ### 1. API Rate Limiting (Stripe, GitHub, Twitter) ```typescript import { mapAsync, retry } from '@ontologyio/receta/async' import { isOk, partition } from '@ontologyio/receta/result' type StripeError = { type: 'rate_limit' | 'card_error' | 'network' message: string statusCode?: number } async function bulkCreateCustomers(emails: string[]) { return mapAsync( emails, (email) => retry(() => stripe.customers.create({ email }), { maxAttempts: 5, delay: 1000, backoff: 2, shouldRetry: (error) => { // Retry on rate limits and network errors const { statusCode, type } = error as StripeError return statusCode === 429 || type === 'network' }, onRetry: (error, attempt) => { console.log(`Retry ${attempt} for ${email}:`, (error as StripeError).message) } }), { concurrency: 10 } // Stripe allows 100/sec, we use 10 to be safe ) } // Usage const customers = await bulkCreateCustomers(emails) if (isOk(customers)) { const [created, failed] = partition(customers.value) console.log(`Created ${created.length} customers`) console.log(`Failed ${failed.length} customers`) } ``` ### 2. GitHub API Pagination with Rate Limit Respect ```typescript import { retry, sleep, type RetryError } from '@ontologyio/receta/async' import { isErr, ok, type Result } from '@ontologyio/receta/result' type GitHubError = { status: number message: string } async function fetchAllRepoIssues( owner: string, repo: string ): Promise> { const issues: Issue[] = [] let page = 1 while (true) { const result = await retry( async () => { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/issues?page=${page}&per_page=100`, { headers: { 'Authorization': `token ${process.env.GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json' } } ) // Check rate limit headers const remaining = parseInt(response.headers.get('X-RateLimit-Remaining') || '0') const resetTime = parseInt(response.headers.get('X-RateLimit-Reset') || '0') if (remaining === 0) { // retry's delay can't vary per error, so wait for the reset here await sleep(resetTime * 1000 - Date.now()) throw { status: 429, message: 'Rate limit exceeded' } } if (!response.ok) { throw { status: response.status, message: await response.text() } } return response.json() }, { maxAttempts: 3, delay: 1000, backoff: 2, maxDelay: 60000, shouldRetry: (error) => { // Retry rate limits and server errors const { status } = error as GitHubError return status === 429 || status >= 500 } } ) if (isErr(result)) { return result } const pageIssues = result.value if (pageIssues.length === 0) { break // No more pages } issues.push(...pageIssues) page++ // Be nice to GitHub - small delay between pages await sleep(100) } return ok(issues) } ``` ### 3. Vercel Deployment Polling ```typescript import { poll } from '@ontologyio/receta/async' import { type Result, err, flatMap, mapErr, match, ok } from '@ontologyio/receta/result' import * as R from 'remeda' type DeploymentStatus = 'QUEUED' | 'BUILDING' | 'READY' | 'ERROR' type Deployment = { id: string status: DeploymentStatus url?: string error?: string } async function waitForDeployment( deploymentId: string ): Promise> { const finished = await poll( async () => { const response = await fetch( `https://api.vercel.com/v13/deployments/${deploymentId}`, { headers: { 'Authorization': `Bearer ${process.env.VERCEL_TOKEN}` } } ) const deployment: Deployment = await response.json() console.log(`Status: ${deployment.status}`) // Stop when deployment is done (success or failure) // null means "not yet", so poll tries again return deployment.status === 'READY' || deployment.status === 'ERROR' ? deployment : null }, { interval: 2000, // Check every 2 seconds maxAttempts: 150, // The default of 10 would give up after 20 seconds timeout: 300000 // 5 minute timeout } ) return R.pipe( finished, mapErr((error) => `Stopped waiting: ${error.type}`), flatMap((deployment) => deployment.status === 'ERROR' ? err(deployment.error || 'Deployment failed') : ok(deployment) ) ) } // Usage const result = await waitForDeployment('dpl_xyz123') match(result, { onOk: (deployment) => { console.log(`βœ… Deployed to: ${deployment.url}`) }, onErr: (error) => { console.log(`❌ Deployment failed: ${error}`) } }) ``` ### 4. AWS Lambda Batch Processing ```typescript import { mapAsync, chunk, timeout } from '@ontologyio/receta/async' import { collect, err, flatMap, isErr, ok, type Result } from '@ontologyio/receta/result' type S3Object = { bucket: string key: string size: number } async function processBatchInLambda( objects: S3Object[] ): Promise> { // Lambda has 15 minute timeout and memory limits // Process in chunks to avoid memory issues const chunks = chunk(objects, 50) // Process 50 at a time const results: ProcessedObject[] = [] for (const objectChunk of chunks) { const chunkResults = await mapAsync( objectChunk, (obj) => timeout(processObject(obj), 60000), // 1 minute per object { concurrency: 5 } // Process 5 objects concurrently ) // Check if any failed: the whole map, or one object's timeout const collected = flatMap(chunkResults, (timed) => collect(timed)) if (isErr(collected)) { return err('Failed to process chunk') } results.push(...collected.value) // Check remaining Lambda time const remainingTime = context.getRemainingTimeInMillis() if (remainingTime < 60000) { // Less than 1 minute left - stop and return what we have console.log('Lambda timeout approaching - stopping early') break } } return ok(results) } ``` ### 5. Search Box with Debouncing ```typescript import { debounce } from '@ontologyio/receta/async' import { type Result, match, tryCatchAsync } from '@ontologyio/receta/result' // Debounce search to avoid overwhelming the API const searchUsers = debounce( (query: string): Promise> => tryCatchAsync( async () => { const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`) return response.json() }, () => 'Search failed' ), { delay: 300, // Wait 300ms after last call leading: false, // Don't execute on leading edge trailing: true // Execute on trailing edge } ) // Only the last keystroke of a burst runs // Earlier calls were replaced, so they all get the result of that last run searchInput.addEventListener('input', async (event) => { const result = await searchUsers(event.target.value) match(result, { onOk: renderUsers, onErr: showError }) }) ``` Debounce belongs where a newer call can replace an older one, such as a search box. Don't put it in front of a server handler: a request whose call was replaced would get the answer to someone else's request. ### 6. Real-Time Data Sync with Throttling ```typescript import { throttle } from '@ontologyio/receta/async' import { err, isOk, ok, type Result } from '@ontologyio/receta/result' type SyncEvent = { id: string type: 'create' | 'update' | 'delete' data: unknown } // Sync changes to remote API, but don't overwhelm it const syncToRemote = throttle( async (events: SyncEvent[]): Promise> => { try { await fetch('https://api.example.com/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ events }) }) return ok(undefined) } catch (error) { return err('Sync failed') } }, { delay: 5000, // At most once every 5 seconds leading: true, // Sync immediately on first change trailing: true // Sync accumulated changes after the delay } ) // In your app let eventBuffer: SyncEvent[] = [] function handleDataChange(event: SyncEvent) { // Buffer events eventBuffer.push(event) // Sync will be throttled automatically syncToRemote(eventBuffer).then(result => { if (isOk(result)) { eventBuffer = [] // Clear buffer on success } }) } ``` ## Mental Model: Guardrails for Async Operations Think of async utilities as **guardrails** on a highway: ``` Input ──[Async Operation]──> Output β”‚ β”‚ β”‚ concurrency β”‚ timeout β”‚ retry β”‚ debounce β”‚ throttle β”‚ poll β”‚ β”‚ └────[Safe]β”€β”€β”€β”€β”€β”€β”˜ ``` - **Without guardrails**: Promises run wild, crash your app, overwhelm APIs - **With guardrails**: Controlled execution, automatic recovery, graceful failures ## Core Utilities ### Concurrency Control ```typescript // mapAsync - Run operations with controlled concurrency mapAsync(items, fn, { concurrency: 10 }) // filterAsync - Filter with async predicate filterAsync(items, asyncPredicate, { concurrency: 5 }) // parallel - Run a list of tasks, optionally with a limit parallel(tasks, { concurrency: 5 }) // sequential - Run tasks one at a time, in order sequential(tasks) ``` ### Retry & Recovery ```typescript // retry - Retry failed operations with backoff retry(fn, { maxAttempts: 3, delay: 1000, backoff: 2, shouldRetry: (error) => isRateLimit(error) }) ``` ### Timing Control ```typescript // timeout - Add timeout to any promise timeout(promise, 5000) // sleep - Delay execution await sleep(1000) // debounce - Execute after silence period debounce(fn, { delay: 300 }) // throttle - Execute at most once per delay throttle(fn, { delay: 1000 }) ``` ### Polling ```typescript // poll - Repeat until fn returns something other than null, undefined or false poll(fn, { interval: 2000, maxAttempts: 30 }) ``` ### Batching ```typescript // batch - Run fn once per batch of items batch(items, processBatch, { batchSize: 50, delayBetweenBatches: 1000 }) // chunk - Split array into chunks chunk([1,2,3,4,5], 2) // [[1,2], [3,4], [5]] ``` ## Integration with Result Pattern Async functions return a Result instead of throwing, for type-safe, composable error handling: ### Why Result + Async? Instead of throwing exceptions, Result-returning functions make errors **explicit in type signatures**: ```typescript import { retry } from '@ontologyio/receta/async' import { map, unwrapOr } from '@ontologyio/receta/result' // Before: Error handling with try-catch async function emailFor(id: string) { try { const user = await fetchUser(id) return user.email } catch (error) { return 'noreply@example.com' } } // After: Error handling with Result async function retriedEmailFor(id: string) { const result = await retry(() => fetchUser(id)) return pipe( result, map(user => user.email), unwrapOr('noreply@example.com') ) } ``` ### Result-Returning Functions | Function | Returns | Error Type | |----------|---------|------------| | `retry()` | `Result` | `RetryError` | | `mapAsync()` | `Result` | `MapAsyncError` | | `filterAsync()` | `Result` | `FilterAsyncError` | | `sequential()` | `Result` | `SequentialError` | | `timeout()` | `Result` | `TimeoutError` | | `poll()` | `Result` | `PollError` | | `batch()` | `Result` | `BatchError` | ### Real-World: GitHub API with Result ```typescript import * as R from 'remeda' import { retry, timeout } from '@ontologyio/receta/async' import { type Result, unwrapOr, map, mapErr, isErr } from '@ontologyio/receta/result' type Repo = { name: string; stars: number } type FetchError = { type: 'network' | 'timeout' | 'rate_limit'; message: string } const fetchRepo = async (owner: string, repo: string): Promise> => { // Combine retry + timeout for resilient API calls const result = await retry( async () => { const res = await timeout( fetch(`https://api.github.com/repos/${owner}/${repo}`), 5000 ) // retry only sees failures that throw if (isErr(res)) { throw res.error } const data = await res.value.json() return { name: data.full_name, stars: data.stargazers_count } }, { maxAttempts: 3, delay: 1000 } ) return R.pipe( result, mapErr(error => ({ type: 'network' as const, message: `Failed after ${error.attempts} attempts` })) ) } // Usage - no try-catch needed! const repo = await fetchRepo('facebook', 'react') const stars = R.pipe(repo, map(r => r.stars), unwrapOr(0)) ``` ### Benefits βœ… **Type-safe errors** - `Result` in signatures βœ… **No exceptions** - Pure functional approach βœ… **Composable** - Chain with `pipe`, `map`, `mapErr` βœ… **Explicit** - Errors visible at compile time See [Patterns](./patterns.mdx) for more. ## When to Use Async Utilities βœ… **Use async utilities when:** - Making API calls that can be rate-limited (Stripe, GitHub, Twitter) - Processing large datasets that could overwhelm memory - Implementing real-time features (polling, webhooks, SSE) - Building reliable background jobs (retries, timeouts) - Handling user input that triggers async operations (debounce/throttle) - Working in resource-constrained environments (Lambda, Edge Functions) ❌ **Don't use async utilities for:** - Single one-off promises (just use `await`) - Operations that must run completely unbounded - Synchronous operations (use regular array methods) - Real-time streams (use observables/async iterators instead) ## Common Patterns ### Pattern 1: API Client with Rate Limiting ```typescript import Bottleneck from 'bottleneck' class StripeClient { // Receta has no rate limiter, so pair retry with one such as Bottleneck private limiter = new Bottleneck({ minTime: 10 }) // 100 requests per second async createCharge(params: ChargeParams) { return this.limiter.schedule(() => retry( () => stripe.charges.create(params), { maxAttempts: 3, delay: 1000, backoff: 2, shouldRetry: (e) => (e as { statusCode?: number }).statusCode === 429 } ) ) } } ``` ### Pattern 2: Batch Processing with Progress ```typescript async function processBatchWithProgress( items: T[], processor: (item: T) => Promise, onProgress: (completed: number, total: number) => void ) { let completed = 0 return mapAsync( items, async (item) => { const result = await processor(item) completed++ onProgress(completed, items.length) return result }, { concurrency: 10 } ) } // Usage await processBatchWithProgress( users, sendEmail, (completed, total) => { console.log(`Sent ${completed}/${total} emails`) } ) ``` ### Pattern 3: Circuit Breaker Receta has no circuit breaker of its own. [Retry and Timeout Patterns](./retry-timeout.mdx#circuit-breaker-pattern) builds one from `retry` and `timeout`. ## What's Next? - **[Concurrency Control](./concurrency.mdx)**: mapAsync, filterAsync, parallel, sequential - **[Retry and Timeout](./retry-timeout.mdx)**: Exponential backoff, custom retry logic, timeouts - **[Debounce and Throttle](./debounce-throttle.mdx)**: Rate limiting user input and events - **[Batching and Polling](./batching.mdx)**: batch, chunk, poll - **[Patterns](./patterns.mdx)**: Recipes for real APIs - **[Migration Guide](./migration.mdx)**: From Promise.all to mapAsync --- **Key Takeaway**: Async utilities provide battle-tested patterns for concurrency, retries, and timing controlβ€”making your async code as reliable as Stripe, GitHub, and Vercel without the complexity of manual Promise management. --- # Batching and Polling > Process large datasets efficiently with controlled batching and monitor long-running operations with polling strategies. ## Table of Contents 1. [Batch Processing](#batch-processing) 2. [Polling Strategies](#polling-strategies) 3. [Real-World Patterns](#real-world-patterns) 4. [Performance Optimization](#performance-optimization) 5. [Error Handling in Batches](#error-handling-in-batches) 6. [Progress Tracking](#progress-tracking) --- ## Batch Processing ### The Problem: Processing Large Datasets ```typescript // DON'T: Process 10,000 payments all at once async function processPayments(payments: Payment[]) { return Promise.all(payments.map(p => stripe.charges.create(p))) // Problems: // - Memory explosion // - API rate limits hit // - All-or-nothing failure // - No progress visibility } ``` ### The Solution: Controlled Batching ```typescript import { batch, mapAsync } from '@ontologyio/receta/async' async function processPayments(payments: Payment[]) { return batch(payments, (group) => // batch hands over 100 payments at a time, mapAsync limits requests within them mapAsync(group, (payment) => stripe.charges.create({ amount: payment.amount, currency: payment.currency, customer: payment.customerId }), { concurrency: 10 }), // 10 concurrent requests per batch { batchSize: 100, // Process 100 at a time delayBetweenBatches: 1000 // Wait 1s between batches }) } ``` --- ## Batch Function Overview ### Basic Usage `fn` runs once per batch and receives the whole batch, which suits bulk APIs. You get one result per batch: ```typescript import { batch } from '@ontologyio/receta/async' // Simple batching const results = await batch( items, async (group) => saveAll(group), { batchSize: 50 } ) // Several batches at once const results = await batch( items, async (group) => saveAll(group), { batchSize: 100, concurrency: 5 } ) // A pause between batches const results = await batch( items, async (group) => saveAll(group), { batchSize: 100, delayBetweenBatches: 500 } ) // batch has no progress callbacks, so count inside fn let completed = 0 const results = await batch( items, async (group) => { const saved = await saveAll(group) completed += group.length console.log(`Progress: ${completed}/${items.length} (${(completed/items.length*100).toFixed(1)}%)`) return saved }, { batchSize: 100 } ) ``` ### Type Signature ```typescript title="Signature" function batch( items: readonly T[], fn: (batch: readonly T[]) => Promise, options?: BatchOptions ): Promise> interface BatchOptions { batchSize?: number // Items per batch (default: 10) concurrency?: number // Batches running at once (default: 1) delayBetweenBatches?: number // Delay between batches in ms (default: 0) } interface BatchError { type: 'batch_error' batchIndex: number itemsInBatch: number underlyingError: unknown } ``` A `delayBetweenBatches` above 0 runs batches one at a time and ignores `concurrency`. `batchIndex` is `-1` when the failed batch can't be identified: with a delay, or when `concurrency` covers every batch. --- ## Chunk Utility ### Breaking Arrays into Batches ```typescript import { chunk } from '@ontologyio/receta/async' // Split array into chunks const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] const batches = chunk(items, 3) // => [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]] // Process each chunk manually for (const batch of batches) { await processBatch(batch) await sleep(1000) // Delay between batches } ``` ### Combining with Remeda ```typescript import * as R from 'remeda' import { chunk, mapAsync } from '@ontologyio/receta/async' // Process in chunks with transformation const results = await R.pipe( largeDataset, chunk(100), (batches) => mapAsync( batches, (group) => mapAsync(group, processItem), // Items in a chunk run together { concurrency: 1 } // Chunks run one at a time ) ) ``` --- ## Batch Size Optimization ### Finding the Right Batch Size ```typescript // Rule of thumb: Start with these defaults and adjust const defaultBatchSizes = { // API Calls externalApi: 100, // Most APIs handle 100/batch well database: 1000, // Databases can handle larger batches emailSending: 50, // Email services often rate-limit // Data Processing imageProcessing: 10, // CPU/memory intensive fileUploads: 5, // I/O bound operations dataTransform: 5000, // Pure computation can be large } // Adaptive batching based on processing time async function adaptiveBatch( items: T[], fn: (item: T) => Promise ): Promise { let batchSize = 100 const results: U[] = [] for (let i = 0; i < items.length; i += batchSize) { const batchItems = items.slice(i, i + batchSize) const startTime = Date.now() const batchResults = await Promise.all(batchItems.map(fn)) results.push(...batchResults) const duration = Date.now() - startTime // Adjust batch size based on duration if (duration < 1000) { batchSize = Math.min(batchSize * 1.5, 500) // Increase } else if (duration > 5000) { batchSize = Math.max(batchSize * 0.5, 10) // Decrease } } return results } ``` --- ## Delays Between Batches ### Rate Limit Compliance ```typescript import { batch, mapAsync } from '@ontologyio/receta/async' // Stripe: 100 requests per second async function batchStripeCharges(charges: ChargeRequest[]) { return batch(charges, (group) => mapAsync( group, (charge) => stripe.charges.create(charge), { concurrency: 10 } ), { batchSize: 100, delayBetweenBatches: 1000 // 1 second delay = 100 req/sec } ) } // SendGrid: 10,000 emails per hour async function batchSendEmails(emails: Email[]) { const perBatch = 100 const batchesPerHour = 10000 / perBatch // 100 batches const delayBetweenBatches = (60 * 60 * 1000) / batchesPerHour // ~36 seconds return batch(emails, async (group) => { const sent = await sendgrid.send(group) console.log(`Batch complete. Next batch in ${delayBetweenBatches}ms`) return sent }, { batchSize: perBatch, delayBetweenBatches } ) } ``` ### Exponential Backoff Between Batches ```typescript async function batchWithBackoff( items: T[], fn: (item: T) => Promise, batchSize: number ): Promise { const batches = chunk(items, batchSize) const results: U[] = [] for (const [index, batch] of batches.entries()) { const delay = Math.min(1000 * Math.pow(2, index), 30000) if (index > 0) { await sleep(delay) } const batchResults = await Promise.all(batch.map(fn)) results.push(...batchResults) } return results } ``` --- ## Polling Strategies ### Poll Function Overview ```typescript import { poll } from '@ontologyio/receta/async' // Poll until condition is met const result = await poll( async () => { const job = await api.getJob(jobId) return job.status === 'completed' ? job : null }, { interval: 1000, // Check every 1 second timeout: 60000, // Give up after 60 seconds maxAttempts: 100, // Or after 100 attempts } ) ``` ### Type Signature ```typescript title="Signature" function poll( fn: () => Promise, options?: PollOptions ): Promise> interface PollOptions { interval?: number // Time between checks (default: 1000) maxAttempts?: number // Max number of attempts (default: 10) timeout?: number // Max total time (no limit by default) shouldContinue?: (attempt: number) => boolean onPoll?: (attempt: number) => void } type PollError = | { type: 'max_attempts'; attempts: number } | { type: 'timeout'; elapsed: number } | { type: 'stopped'; attempt: number } ``` - `null`, `undefined` and `false` mean "not yet". Anything else, including `0` and `''`, stops polling and becomes the `Ok` value. - If `fn` throws, `poll` rejects instead of returning an `Err`. Return failures as values, as [the examples below](#job-status-polling) do. - `maxAttempts` defaults to 10, so raise it along with a long `timeout`, or the attempts run out first. - The wait is `interval` every time. There's no backoff. - After a timeout, `poll` returns, but the loop keeps calling `fn` until it runs out of attempts. --- ## Working with PollError `poll` returns a `Result`, so you can tell the failure modes apart without exceptions: ### Real-World: CI/CD Job Monitoring with Result ```typescript import { poll } from '@ontologyio/receta/async' import { isErr } from '@ontologyio/receta/result' type JobStatus = { id: string status: 'pending' | 'running' | 'success' | 'failed' result?: string error?: string } const monitorCIJob = async (jobId: string) => { const result = await poll( async () => { const response = await fetch(`/api/jobs/${jobId}`) const job: JobStatus = await response.json() if (job.status === 'success' || job.status === 'failed') { return job } return null // Keep polling }, { interval: 5000, maxAttempts: 60, // 5 minutes max timeout: 300000, onPoll: (attempt) => { console.log(`Checking job ${jobId} (attempt ${attempt})`) } } ) // Handle different error types if (isErr(result)) { if (result.error.type === 'timeout') { return { status: 'timeout', message: `Job timed out after ${result.error.elapsed}ms` } } if (result.error.type === 'max_attempts') { return { status: 'failed', message: `Max polling attempts (${result.error.attempts}) reached` } } return { status: 'stopped', message: 'Polling stopped' } } return { status: result.value.status, result: result.value.result } } ``` ### Real-World: Order Processing with Result ```typescript import { poll, retry } from '@ontologyio/receta/async' import { type Result, isErr, mapErr } from '@ontologyio/receta/result' import * as R from 'remeda' type Order = { id: string; status: 'processing' | 'complete'; total: number } const waitForOrder = async (orderId: string): Promise> => { // Poll with retry on network failures const pollRes = await poll( async () => { const res = await retry( () => fetch(`/api/orders/${orderId}`).then(r => r.json()), { maxAttempts: 2, delay: 500 } ) if (isErr(res)) return null // Retry polling const order = res.value as Order return order.status === 'complete' ? order : null }, { interval: 2000, maxAttempts: 30 } ) return R.pipe( pollRes, mapErr(error => { if (error.type === 'max_attempts') { return `Order ${orderId} processing timed out` } return `Order polling failed: ${error.type}` }) ) } ``` ### Benefits βœ… **Discriminated error types** - Know exactly why polling failed βœ… **No exceptions** - Pure functional polling βœ… **Composable** - Chain with other Result operations βœ… **Full context** - Access attempts, elapsed time, etc. ## Polling Patterns ### Job Status Polling ```typescript import { poll, type PollError } from '@ontologyio/receta/async' import { type Result, err, flatten, ok } from '@ontologyio/receta/result' // AWS Batch job monitoring async function waitForBatchJob( jobId: string ): Promise> { const finished = await poll( async () => { const job = await batchClient.describeJobs({ jobs: [jobId] }) const status = job.jobs[0]?.status if (status === 'SUCCEEDED') { return ok({ jobId, status, output: job.jobs[0].container.output }) } if (status === 'FAILED') { return err(`Job failed: ${job.jobs[0].statusReason}`) } return null // Still running }, { interval: 5000, // Check every 5 seconds maxAttempts: 720, // Enough attempts to reach the timeout timeout: 3600000, // 1 hour timeout onPoll: (attempt) => { console.log(`Checking job status (attempt ${attempt})...`) } } ) return flatten(finished) } ``` ### CI/CD Pipeline Monitoring ```typescript // GitHub Actions workflow polling async function waitForWorkflow( owner: string, repo: string, runId: number ): Promise> { return poll( async () => { const { data } = await octokit.actions.getWorkflowRun({ owner, repo, run_id: runId }) if (data.status === 'completed') { return data } return null // Still in progress }, { interval: 10000, // Check every 10 seconds maxAttempts: 180, // Enough attempts to reach the timeout timeout: 1800000, // 30 minute timeout onPoll: (attempt) => { if (attempt % 6 === 0) { // Log every minute console.log(`Workflow still running (${attempt * 10}s elapsed)...`) } } } ) } ``` ### Payment Processing Status ```typescript // Stripe charge confirmation polling async function waitForChargeConfirmation( chargeId: string ): Promise> { const finished = await poll( async () => { const charge = await stripe.charges.retrieve(chargeId) if (charge.status === 'succeeded') { return ok(charge) } if (charge.status === 'failed') { return err(`Charge failed: ${charge.failure_message}`) } return null // Still pending }, { interval: 2000, maxAttempts: 30 // 60 seconds max } ) return flatten(finished) } ``` --- ## Combining Poll with Timeout ### Time Limit Option `poll` takes a `timeout` option, so there's no separate promise to race. Give it enough attempts that the timeout is what stops it: ```typescript import { poll, type PollError } from '@ontologyio/receta/async' async function pollWithTimeout( fn: () => Promise, pollIntervalMs: number, timeoutMs: number ): Promise> { return poll(fn, { interval: pollIntervalMs, maxAttempts: Math.ceil(timeoutMs / pollIntervalMs) + 1, timeout: timeoutMs }) } // Usage const result = await pollWithTimeout( async () => { const status = await checkJobStatus(jobId) return status.complete ? status : null }, 1000, 30000 ) ``` --- ## Real-World Patterns ### Pattern 1: Data Import/Export ```typescript // CSV export with batching and progress async function exportUsersToCSV( filters: UserFilters ): Promise> { const users = await db.user.findMany({ where: filters }) if (users.length === 0) { return err({ type: 'no_data' }) } const csvRows: string[] = ['id,email,name,created_at'] const exported = await batch(users, async (group) => { const rows = group.map((user) => [ user.id, user.email, user.name, user.createdAt.toISOString() ].join(',')) csvRows.push(...rows) console.log(`Exported ${csvRows.length - 1}/${users.length} users`) }, { batchSize: 1000 } ) if (isErr(exported)) { return err({ type: 'export_failed' }) } const csv = csvRows.join('\n') const filename = `users_export_${Date.now()}.csv` await fs.writeFile(filename, csv) return ok(filename) } ``` ### Pattern 2: Email Sending Batches ```typescript // SendGrid bulk email with rate limiting interface EmailCampaign { recipients: EmailAddress[] template: string subject: string } async function sendEmailCampaign( campaign: EmailCampaign ): Promise> { const report: CampaignReport = { sent: 0, failed: 0, errors: [] } await batch(campaign.recipients, async (group) => { const sent = await mapAsync(group, async (recipient) => { const result = await tryCatchAsync(async () => { await sendgrid.send({ to: recipient.email, subject: campaign.subject, templateId: campaign.template, dynamicTemplateData: recipient.data }) }) if (isOk(result)) { report.sent++ } else { report.failed++ report.errors.push({ recipient: recipient.email, error: result.error }) } }, { concurrency: 10 }) console.log(`Sent batch. Total sent: ${report.sent}`) return sent }, { batchSize: 100, // SendGrid batch limit delayBetweenBatches: 1000 // Rate limit compliance } ) return ok(report) } ``` ### Pattern 3: Database Migrations ```typescript // Backfill database records in batches async function backfillUserScores(): Promise> { const usersWithoutScores = await db.user.findMany({ where: { score: null }, select: { id: true } }) console.log(`Found ${usersWithoutScores.length} users to backfill`) const report: MigrationReport = { processed: 0, failed: 0, errors: [] } await batch(usersWithoutScores, async (group) => { const backfilled = await mapAsync(group, async (user) => { const result = await tryCatchAsync(async () => { const score = await calculateUserScore(user.id) await db.user.update({ where: { id: user.id }, data: { score } }) }) if (isOk(result)) { report.processed++ } else { report.failed++ report.errors.push({ userId: user.id, error: result.error }) } }, { concurrency: 20 }) const completed = report.processed + report.failed const percent = ((completed / usersWithoutScores.length) * 100).toFixed(1) console.log(`Migration progress: ${completed}/${usersWithoutScores.length} (${percent}%)`) return backfilled }, { batchSize: 500, // Database can handle larger batches delayBetweenBatches: 100 // Small delay to not overwhelm DB } ) return ok(report) } ``` ### Pattern 4: Order Processing ```typescript // Process pending orders in batches interface OrderProcessor { processPendingOrders(): Promise> } const orderProcessor: OrderProcessor = { processPendingOrders: async () => { const pendingOrders = await db.order.findMany({ where: { status: 'pending' }, include: { items: true, customer: true } }) const report: ProcessingReport = { processed: 0, skipped: 0, failed: 0, totalRevenue: 0 } await batch(pendingOrders, async (group) => { const handled = await mapAsync(group, async (order) => { // Check inventory const inventoryCheck = await checkInventory(order.items) if (!inventoryCheck.available) { report.skipped++ return } // Process payment const paymentResult = await processPayment(order) if (isErr(paymentResult)) { report.failed++ await db.order.update({ where: { id: order.id }, data: { status: 'failed', error: paymentResult.error } }) return } // Update order await db.order.update({ where: { id: order.id }, data: { status: 'completed', processedAt: new Date() } }) report.processed++ report.totalRevenue += order.total }, { concurrency: 5 }) console.log(`Processed: ${report.processed}, Failed: ${report.failed}, Skipped: ${report.skipped}`) return handled }, { batchSize: 50, delayBetweenBatches: 500 } ) return ok(report) } } ``` --- ## Performance Optimization ### Memory Management ```typescript // DON'T: Load all results in memory async function processLargeDataset(items: Item[]) { const results = await batch(items, processGroup, { batchSize: 1000 }) return results // 1M items = huge memory usage } // DO: Stream results to disk async function processLargeDataset(items: Item[]) { const outputStream = fs.createWriteStream('results.json') outputStream.write('[') let first = true await batch(items, async (group) => { for (const result of await processGroup(group)) { const json = JSON.stringify(result) outputStream.write(first ? json : `,${json}`) first = false } }, { batchSize: 1000 } ) outputStream.write(']') outputStream.end() } ``` ### Optimal Concurrency ```typescript import os from 'os' // Auto-detect optimal concurrency function getOptimalConcurrency(taskType: 'cpu' | 'io'): number { const cpuCount = os.cpus().length if (taskType === 'cpu') { return cpuCount // CPU-bound: match CPU cores } else { return cpuCount * 4 // I/O-bound: can handle more } } // Usage await batch(items, processGroup, { batchSize: 100, concurrency: getOptimalConcurrency('io') }) ``` --- ## Error Handling in Batches ### Fail Fast vs. Continue on Error ```typescript // Strategy 1: Fail fast async function batchFailFast(items: T[], fn: (item: T) => Promise) { return batch( items, // unwrap throws, which is what batch counts as a failure async (group) => unwrap(await mapAsync(group, fn)), { batchSize: 100 } ) // The first failed batch stops the rest } // Strategy 2: Collect errors, continue processing async function batchContinueOnError( items: T[], fn: (item: T) => Promise ): Promise<{ results: U[]; errors: Array<{ item: T; error: unknown }> }> { const results: U[] = [] const errors: Array<{ item: T; error: unknown }> = [] await batch(items, (group) => mapAsync(group, async (item) => { const result = await tryCatchAsync(() => fn(item)) if (isOk(result)) { results.push(result.value) } else { errors.push({ item, error: result.error }) } }), { batchSize: 100 } ) return { results, errors } } ``` ### Retry Failed Items ```typescript type BatchFailure = { message: string; failedItems: unknown[] } async function batchWithRetry( items: T[], fn: (item: T) => Promise, maxRetries = 3 ): Promise> { let currentItems = items let allResults: U[] = [] for (let attempt = 0; attempt <= maxRetries; attempt++) { const { results, errors } = await batchContinueOnError(currentItems, fn) allResults.push(...results) if (errors.length === 0) { return ok(allResults) } if (attempt === maxRetries) { return err({ message: `${errors.length} items failed after ${maxRetries} retries`, failedItems: errors }) } // Retry only failed items currentItems = errors.map(e => e.item) console.log(`Retrying ${currentItems.length} failed items (attempt ${attempt + 1})`) } return err({ message: 'No attempts ran', failedItems: [] }) } ``` --- ## Progress Tracking ### Console Progress Bar ```typescript import { batch, mapAsync } from '@ontologyio/receta/async' async function processWithProgress( items: T[], fn: (item: T) => Promise ) { const total = items.length let done = 0 return batch(items, (group) => mapAsync(group, async (item) => { const result = await fn(item) done++ const percent = ((done / total) * 100).toFixed(1) const bar = 'β–ˆ'.repeat(Math.floor(done / total * 40)) const empty = 'β–‘'.repeat(40 - bar.length) process.stdout.write(`\r[${bar}${empty}] ${percent}% (${done}/${total})`) return result }), { batchSize: 100 } ) } ``` ### Structured Progress Reporting ```typescript interface ProgressReport { stage: string completed: number total: number percent: number estimatedTimeRemaining: number errors: number } class BatchProgressTracker { private startTime = Date.now() private completed = 0 private errors = 0 constructor( private total: number, private stage: string, private onUpdate: (report: ProgressReport) => void ) {} update(completed: number, errorOccurred = false) { this.completed = completed if (errorOccurred) this.errors++ const elapsed = Date.now() - this.startTime const rate = completed / elapsed const remaining = this.total - completed const estimatedTimeRemaining = remaining / rate this.onUpdate({ stage: this.stage, completed, total: this.total, percent: (completed / this.total) * 100, estimatedTimeRemaining, errors: this.errors }) } } // Usage async function processWithTracking( items: T[], fn: (item: T) => Promise ) { const tracker = new BatchProgressTracker( items.length, 'Processing items', (report) => { console.log(`${report.stage}: ${report.completed}/${report.total}`) console.log(`ETA: ${Math.round(report.estimatedTimeRemaining / 1000)}s`) if (report.errors > 0) { console.log(`Errors: ${report.errors}`) } } ) let completed = 0 return batch(items, (group) => mapAsync(group, async (item) => { const result = await tryCatchAsync(() => fn(item)) completed++ tracker.update(completed, isErr(result)) return result }), { batchSize: 100 } ) } ``` --- ## Summary ### When to Use Batching Use `batch()` when: - Processing large datasets (>100 items) - Calling rate-limited APIs - Managing memory usage - Need progress tracking - Want controlled concurrency ### When to Use Polling Use `poll()` when: - Waiting for async jobs to complete - Monitoring external system status - Processing webhooks/callbacks - Checking for data availability - Long-running operations ### Best Practices 1. **Start conservative**: Small batch sizes, low concurrency 2. **Monitor and adjust**: Track performance, optimize iteratively 3. **Handle errors gracefully**: Continue on error, retry failed items 4. **Respect rate limits**: Add delays between batches 5. **Track progress**: Keep users informed on long operations 6. **Test edge cases**: Empty arrays, all failures, timeouts 7. **Use backoff where it exists**: `retry` has it, while `poll` waits a fixed `interval` --- # Concurrency Control > Managing parallel operations, rate limits, and performance optimization in async workflows. ## Overview Concurrency control lets you execute multiple async operations efficiently while respecting system constraints like API rate limits, memory usage, and CPU capacity. | Function | Purpose | Real-World Analogy | |----------|---------|-------------------| | `mapAsync(items, fn, { concurrency })` | Transform items in parallel with limit | Processing orders with N workers | | `filterAsync(items, predicate, { concurrency })` | Filter items using async predicate | Checking inventory across warehouses | | `parallel(tasks)` | Run all tasks simultaneously | Fetching data from multiple APIs at once | | `sequential(tasks)` | Run tasks one after another | Processing payments in order | --- ## `mapAsync` - Controlled Parallel Mapping **When to use**: You need to transform many items using async operations, but want to limit how many run at once. ### Signature ```typescript title="Signature" function mapAsync( items: readonly T[], fn: (item: T, index: number) => Promise, options?: { concurrency?: number } ): Promise> ``` Leave `concurrency` out and every item starts at once. ### Real-World: GitHub Bulk Repository Fetch **Problem**: Fetch data for 1000 repositories, but GitHub limits you to 5000 requests/hour. ```typescript import { mapAsync } from '@ontologyio/receta/async' type Repo = { name: string stars: number language: string } // Fetch repos with concurrency limit to avoid rate limit const fetchRepos = async (repoNames: string[]) => { return mapAsync( repoNames, async (name) => { const [owner, repo] = name.split('/') const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Authorization: `token ${GITHUB_TOKEN}` } }) const data = await res.json() return { name: data.name, stars: data.stargazers_count, language: data.language } }, { concurrency: 10 } // Max 10 requests at once ) } // Usage const repos = await fetchRepos([ 'facebook/react', 'microsoft/typescript', 'vercel/next.js', // ... 997 more repos ]) // repos is a Result: Ok with every repo, or Err with the failure ``` **Why concurrency: 10?** - GitHub allows 5000 requests/hour = ~83/minute = ~1.4/second - 10 concurrent requests is safe and fast - Too high (100+) risks hitting rate limit - Too low (1-2) wastes time ### Real-World: Stripe Batch Payment Processing **Problem**: Process 500 customer payments, but Stripe limits to 100 requests/second. ```typescript import { mapAsync, type MapAsyncError } from '@ontologyio/receta/async' import { isOk, type Result } from '@ontologyio/receta/result' type Payment = { customerId: string amount: number currency: string } type PaymentResult = { id: string status: 'succeeded' | 'failed' customerId: string } const processPayments = async ( payments: Payment[] ): Promise> => { return mapAsync( payments, async (payment) => { const charge = await stripe.charges.create({ amount: payment.amount, currency: payment.currency, customer: payment.customerId }) return { id: charge.id, status: charge.status as 'succeeded' | 'failed', customerId: payment.customerId } }, { concurrency: 50 } // 50 concurrent = safe for 100/sec limit ) } // Usage const payments: Payment[] = [ { customerId: 'cus_123', amount: 1000, currency: 'usd' }, { customerId: 'cus_456', amount: 2500, currency: 'usd' }, // ... 498 more ] const results = await processPayments(payments) if (isOk(results)) { const succeeded = results.value.filter(r => r.status === 'succeeded') console.log(`Processed ${succeeded.length} payments`) } ``` ### Real-World: Image Processing Pipeline **Problem**: Resize 1000 product images, but each resize uses 100MB RAM. ```typescript import sharp from 'sharp' import fs from 'fs/promises' const resizeImages = async (imagePaths: string[]) => { return mapAsync( imagePaths, async (path) => { const outputPath = path.replace('.jpg', '-thumb.jpg') await sharp(path) .resize(300, 300, { fit: 'cover' }) .jpeg({ quality: 80 }) .toFile(outputPath) return outputPath }, { concurrency: 4 } // Only 4 images at once to limit memory ) } // Usage const images = await fs.readdir('./products') const thumbnails = await resizeImages( images.map(img => `./products/${img}`) ) ``` **Why concurrency: 4?** - Each resize uses ~100MB RAM - 4 concurrent = ~400MB total - Prevents OOM errors on low-memory servers - Adjust based on available RAM ### Real-World: Database Bulk Operations **Problem**: Update 10,000 user records, but database connection pool has 20 connections. ```typescript const updateUsers = async (updates: Array<{ id: string; name: string }>) => { return mapAsync( updates, async (update) => { return db.user.update({ where: { id: update.id }, data: { name: update.name } }) }, { concurrency: 15 } // Leave 5 connections for other queries ) } ``` ### Performance Characteristics ```typescript // Benchmark: Fetching 100 URLs const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/item/${i}`) const get = (url: string) => fetch(url) // No limit (concurrency: Infinity) - 2.3 seconds, might fail await mapAsync(urls, get) // All 100 at once! // concurrency: 50 - 2.5 seconds, stable await mapAsync(urls, get, { concurrency: 50 }) // concurrency: 10 - 4.1 seconds, very stable await mapAsync(urls, get, { concurrency: 10 }) // concurrency: 1 - 23 seconds, sequential await mapAsync(urls, get, { concurrency: 1 }) ``` **Rule of Thumb**: Start with concurrency = 10, then adjust based on: - API rate limits (lower) - Memory constraints (lower) - Network capacity (higher) - Need for speed (higher) --- ## Handling Failures with Result **When to use**: Every `mapAsync` call. It returns a `Result`, so a failure is a value you handle rather than an exception you catch. ### Error Type ```typescript title="Signature" type MapAsyncError = { type: 'map_async_error' index: number item: unknown error: unknown } ``` `index` and `item` say which item failed. ### What Counts as a Failure `mapAsync` fails when the mapper throws or rejects. A mapper that returns a `Result` hasn't failed, whatever that Result holds, so you choose how a failure behaves: - **Stop at the first failure**: let the mapper throw. Under a limit, no new items start after a failure. - **Keep going and see every outcome**: have the mapper return a `Result`, then use `partition` or `collect` on the array. ### Real-World: Stripe Batch Payments ```typescript import { mapAsync, retry } from '@ontologyio/receta/async' import { type Result, isOk, map, mapErr, partition } from '@ontologyio/receta/result' import * as R from 'remeda' type Payment = { id: string; amount: number; status: 'success' | 'failed' } type PaymentError = { type: 'insufficient_funds' | 'network'; message: string } const processPayment = async ( customerId: string ): Promise> => { // Returns Result instead of throwing const result = await retry(() => stripe.charges.create({ customer: customerId, amount: 1000 }) ) return R.pipe( result, map(charge => ({ id: charge.id, amount: charge.amount, status: 'success' as const })), mapErr(error => ({ type: 'network' as const, message: String(error.lastError) })) ) } // Process payments with concurrency control const outcome = await mapAsync( customerIds, processPayment, { concurrency: 5 } ) // processPayment never throws, so every customer gets an answer if (isOk(outcome)) { const [paid, failed] = partition(outcome.value) console.log(`${paid.length} paid, ${failed.length} failed`) // Retry just the failed payments } ``` ### Comparison: Throwing Mapper vs Result Mapper | Feature | Mapper throws | Mapper returns `Result` | |---------|---------------|-------------------------| | After a failure | Stops starting new items | Keeps going | | You get | `Err` with the first failure | `Ok` with one `Result` per item | | Type | `Result` | `Result[], MapAsyncError>` | | Error type | `unknown`, in `error.error` | Your own `E` | | Use case | All-or-nothing work | Partial success | ### Real-World: GitHub Repository Validation ```typescript type RepoData = { name: string; stars: number; language: string } type RepoError = { type: 'not_found' | 'rate_limit' | 'network'; repo: string } const fetchRepo = async (repoName: string): Promise> => { try { const [owner, repo] = repoName.split('/') const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Authorization: `token ${GITHUB_TOKEN}` } }) if (res.status === 404) { return err({ type: 'not_found', repo: repoName }) } if (res.status === 429) { return err({ type: 'rate_limit', repo: repoName }) } if (!res.ok) { return err({ type: 'network', repo: repoName }) } const data = await res.json() return ok({ name: data.name, stars: data.stargazers_count, language: data.language }) } catch { return err({ type: 'network', repo: repoName }) } } // Fetch multiple repos with Result handling const outcome = await mapAsync( ['facebook/react', 'microsoft/typescript', 'invalid/repo'], fetchRepo, { concurrency: 10 } ) if (isOk(outcome)) { // collect gives every repo, or the first failure const repos = collect(outcome.value) if (isErr(repos)) { const { repo, type } = repos.error console.error(`Failed at ${repo} (${type})`) } else { console.log(`Successfully fetched ${repos.value.length} repositories`) } } ``` ### Real-World: File Processing Pipeline ```typescript import { isErr, isOk, ok, partition, tryCatchAsync, type Result } from '@ontologyio/receta/result' type ProcessedFile = { path: string; size: number; hash: string } type FileError = { type: 'read' | 'parse' | 'write'; path: string; message: string } const processFile = async (path: string): Promise> => { // Read file const readResult = await tryCatchAsync( () => fs.readFile(path), (error) => ({ type: 'read' as const, path, message: String(error) }) ) if (isErr(readResult)) return readResult // Process file const processResult = await tryCatchAsync( () => processData(readResult.value), (error) => ({ type: 'parse' as const, path, message: String(error) }) ) if (isErr(processResult)) return processResult // Return result return ok({ path, size: readResult.value.length, hash: processResult.value.hash }) } // Process files with concurrency const outcome = await mapAsync( filePaths, processFile, { concurrency: 4 } ) // Handle errors gracefully if (isOk(outcome)) { const [processed, failed] = partition(outcome.value) // Log the failures and keep what worked failed.forEach(({ path, message }) => console.error(`Failed to process ${path}:`, message)) console.log(`Processed ${processed.length} files`) console.log(`Total size: ${processed.reduce((sum, f) => sum + f.size, 0)} bytes`) } ``` ### Composing with Result Functions Because `mapAsync` returns a Result, it composes naturally with other Result functions: ```typescript import { collect, flatMap, isErr, map } from '@ontologyio/receta/result' import { mapAsync } from '@ontologyio/receta/async' import * as R from 'remeda' // Complex pipeline with Result composition const processOrders = async (orderIds: string[]) => { const payments = R.pipe( // Fetch orders (returns Result) await mapAsync(orderIds, fetchOrder, { concurrency: 10 }), // Validate orders flatMap(orders => collect(orders.map(validateOrder)) ), // Transform to payment requests map(orders => orders.map(order => ({ customerId: order.customerId, amount: order.total })) ) ) if (isErr(payments)) return payments // Process payments (returns Result) return mapAsync(payments.value, processPayment, { concurrency: 5 }) } // Single error-handling point const result = await processOrders(['order-1', 'order-2', 'order-3']) if (isErr(result)) { console.error('Pipeline failed:', result.error) } else { console.log(`Successfully processed ${result.value.length} orders`) } ``` ### Why a Result 1. **No try-catch**: Errors are values, not exceptions 2. **Type-safe**: `Result` says the call can fail 3. **Detailed errors**: Under a limit, know which item failed and why 4. **Composable**: Works with all Result functions 5. **Fail-fast**: Under a limit, stops starting new items after a failure --- ## `filterAsync` - Async Filtering **When to use**: Filter items using an async condition (like checking if file exists, or if product is in stock). ### Signature ```typescript title="Signature" function filterAsync( items: readonly T[], predicate: (item: T, index: number) => Promise, options?: { concurrency?: number } ): Promise> ``` A predicate that throws gives an `Err` whose `underlyingError` is the `MapAsyncError`. ### Real-World: Check Inventory Across Warehouses **Problem**: Filter products that are in stock by checking multiple warehouse APIs. ```typescript import { filterAsync } from '@ontologyio/receta/async' type Product = { id: string name: string sku: string } const isInStock = async (product: Product): Promise => { // Check all warehouses in parallel const warehouses = ['US-EAST', 'US-WEST', 'EU-CENTRAL'] const results = await Promise.all( warehouses.map(async (warehouse) => { const res = await fetch( `https://warehouse.api.com/${warehouse}/stock/${product.sku}` ) const data = await res.json() return data.quantity > 0 }) ) return results.some(inStock => inStock) // In stock if ANY warehouse has it } const filterInStockProducts = async (products: Product[]) => { return filterAsync( products, isInStock, { concurrency: 20 } // Check 20 products at once ) } // Usage const allProducts: Product[] = [ { id: '1', name: 'iPhone', sku: 'IPHONE-14' }, { id: '2', name: 'MacBook', sku: 'MACBOOK-PRO' }, // ... 998 more ] const inStock = await filterInStockProducts(allProducts) if (isOk(inStock)) { console.log(`${inStock.value.length} products available`) } ``` ### Real-World: Validate S3 Objects Exist **Problem**: Filter a list of S3 keys to only those that exist. ```typescript import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3' const s3 = new S3Client({ region: 'us-east-1' }) const objectExists = async (key: string): Promise => { try { await s3.send(new HeadObjectCommand({ Bucket: 'my-bucket', Key: key })) return true } catch { return false } } const filterExistingFiles = async (keys: string[]) => { return filterAsync( keys, objectExists, { concurrency: 30 } // S3 can handle high concurrency ) } // Usage const requestedFiles = [ 'uploads/avatar-123.jpg', 'uploads/avatar-456.jpg', 'uploads/deleted-file.jpg', // Doesn't exist // ... more files ] const existingFiles = await filterExistingFiles(requestedFiles) ``` ### Real-World: Find Valid Email Addresses **Problem**: Filter email list by actually checking if addresses are deliverable. ```typescript const isValidEmail = async (email: string): Promise => { try { // Use email validation API const res = await fetch( `https://api.emailvalidation.com/verify?email=${email}`, { headers: { 'X-API-Key': API_KEY } } ) const data = await res.json() return data.status === 'deliverable' } catch { return false } } const filterValidEmails = async (emails: string[]) => { return filterAsync( emails, isValidEmail, { concurrency: 5 } // Respect API rate limit ) } // Usage const emailList = ['user1@example.com', 'fake@invalid.xyz', /* ... */] const validEmails = await filterValidEmails(emailList) ``` --- ## `parallel` - Run All Tasks Together **When to use**: You have independent tasks that should all run simultaneously (fetching from multiple APIs, running independent calculations). ### Signature ```typescript title="Signature" function parallel( tasks: ReadonlyArray<() => Promise>, options?: { concurrency?: number } ): Promise> ``` `parallel` is `mapAsync` over a list of functions, so it takes the same `concurrency` limit. Every task shares one type `T`, and the values come back as an array inside the Result. ### Real-World: Dashboard Data Loading **Problem**: Load dashboard data from 5 different sources - should all load at once. ```typescript import { parallel } from '@ontologyio/receta/async' type DashboardData = { userStats: { total: number; active: number } recentOrders: Order[] revenue: { today: number; month: number } notifications: Notification[] systemHealth: { status: 'healthy' | 'degraded' } } const loadDashboard = async () => { const loaded = await parallel([ () => fetch('/api/users/stats').then(r => r.json()), () => fetch('/api/orders/recent').then(r => r.json()), () => fetch('/api/revenue').then(r => r.json()), () => fetch('/api/notifications').then(r => r.json()), () => fetch('/api/health').then(r => r.json()) ]) return map( loaded, ([userStats, recentOrders, revenue, notifications, systemHealth]): DashboardData => ({ userStats, recentOrders, revenue, notifications, systemHealth }) ) } // Usage - All 5 APIs load in parallel const data = await loadDashboard() // Total time = slowest API (not sum of all APIs) ``` ### Real-World: Multi-Region Deployment **Problem**: Deploy to 3 cloud regions simultaneously. ```typescript const deployToRegion = async (region: string, artifact: string) => { console.log(`Deploying to ${region}...`) // Upload artifact, update servers, health check await uploadArtifact(region, artifact) await updateServers(region) await healthCheck(region) console.log(`${region} deployed!`) } const deployGlobally = async (artifact: string) => { const regions = ['us-east-1', 'eu-west-1', 'ap-southeast-1'] const deployed = await parallel( regions.map(region => () => deployToRegion(region, artifact)) ) if (isOk(deployed)) { console.log('Global deployment complete!') } } // All regions deploy at the same time await deployGlobally('v2.3.0') ``` ### Real-World: Microservices Health Check **Problem**: Check health of 10 microservices in parallel. ```typescript type ServiceHealth = { name: string status: 'healthy' | 'unhealthy' responseTime: number } const checkService = async (name: string, url: string): Promise => { const start = Date.now() try { const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) }) const responseTime = Date.now() - start return { name, status: res.ok ? 'healthy' : 'unhealthy', responseTime } } catch { return { name, status: 'unhealthy', responseTime: Date.now() - start } } } const checkAllServices = async () => { const services = { auth: 'https://auth.api.com', payments: 'https://payments.api.com', inventory: 'https://inventory.api.com', shipping: 'https://shipping.api.com', notifications: 'https://notifications.api.com' } const results = await parallel( Object.entries(services).map(([name, url]) => () => checkService(name, url) ) ) return results } // Usage const health = await checkAllServices() if (isOk(health)) { health.value.forEach(({ name, status, responseTime }) => { console.log(`${name}: ${status} (${responseTime}ms)`) }) } ``` ### Performance Characteristics ```typescript // Sequential (slow) const slow = async () => { const a = await fetchA() // 1 second const b = await fetchB() // 1 second const c = await fetchC() // 1 second return [a, b, c] } await slow() // Total: 3 seconds // Parallel (fast) const fast = async () => { return parallel([fetchA, fetchB, fetchC]) } await fast() // Total: 1 second (all run at once) ``` --- ## `sequential` - Run Tasks In Order **When to use**: Tasks depend on each other, or you need guaranteed ordering (payment processing, database migrations). ### Signature ```typescript title="Signature" function sequential( tasks: ReadonlyArray<() => Promise> ): Promise> ``` The first task to fail stops the rest. `SequentialError` holds its `taskIndex`, the `completedTasks` count, and the `error`. ### Real-World: Payment Processing Flow **Problem**: Process payment steps in exact order - can't capture before authorization! ```typescript import { sequential } from '@ontologyio/receta/async' type PaymentFlow = { authorized: AuthResult captured: CaptureResult receipt: ReceiptResult notification: NotificationResult } const processPayment = async ( customerId: string, amount: number ) => { const steps = await sequential([ // Step 1: Authorize card async () => { console.log('Authorizing...') return stripe.authorizations.create({ customer: customerId, amount }) }, // Step 2: Capture funds (only after authorization!) async () => { console.log('Capturing...') return stripe.captures.create({ /* ... */ }) }, // Step 3: Generate receipt async () => { console.log('Generating receipt...') return generateReceipt(customerId, amount) }, // Step 4: Send notification async () => { console.log('Sending notification...') return sendEmail(customerId, 'Payment received') } ]) return map( steps, ([authorized, captured, receipt, notification]): PaymentFlow => ({ authorized, captured, receipt, notification }) ) } // Each step waits for previous to complete await processPayment('cus_123', 5000) ``` ### Real-World: Database Migration Pipeline **Problem**: Run migrations in order - migration 003 depends on 002. ```typescript type Migration = { version: string name: string sql: string } const migrations: Migration[] = [ { version: '001', name: 'create_users', sql: 'CREATE TABLE users...' }, { version: '002', name: 'add_email_index', sql: 'CREATE INDEX...' }, { version: '003', name: 'add_users_status', sql: 'ALTER TABLE users...' } ] const runMigration = async (migration: Migration) => { console.log(`Running migration ${migration.version}: ${migration.name}`) await db.execute(migration.sql) await db.migrations.insert({ version: migration.version }) console.log(`βœ“ Completed ${migration.version}`) } const migrate = async () => { const applied = await sequential( migrations.map(m => () => runMigration(m)) ) if (isErr(applied)) { console.error(`Stopped at migration ${applied.error.taskIndex}`) } else { console.log('All migrations complete!') } } // Runs in exact order: 001 β†’ 002 β†’ 003 await migrate() ``` ### Real-World: Video Processing Pipeline **Problem**: Process video in stages - can't add subtitles before transcoding! ```typescript const processVideo = async (videoId: string) => { const stages = await sequential([ // Step 1: Transcode to different resolutions async () => { console.log('Transcoding video...') return transcode(videoId, ['720p', '1080p', '4k']) }, // Step 2: Generate thumbnail from transcoded video async () => { console.log('Generating thumbnail...') return generateThumbnail(videoId) }, // Step 3: Generate subtitles async () => { console.log('Generating subtitles...') return generateSubtitles(videoId) }, // Step 4: Upload everything to CDN async () => { console.log('Uploading to CDN...') return uploadToCDN(videoId) } ]) return map(stages, ([transcoded, thumbnail, subtitles, uploaded]) => ({ transcoded, thumbnail, subtitles, uploaded })) } ``` --- ## Concurrency Patterns ### Pattern 1: Rate-Limited API Calls **Problem**: GitHub allows 5000 requests/hour. Need to fetch 1000 repos. ```typescript import { mapAsync } from '@ontologyio/receta/async' const GITHUB_RATE_LIMIT = 5000 // requests per hour const SAFE_CONCURRENCY = 10 // requests at once const fetchGitHubRepos = async (repos: string[]) => { return mapAsync( repos, async (repo) => { const res = await fetch(`https://api.github.com/repos/${repo}`) return res.json() }, { concurrency: SAFE_CONCURRENCY } ) } // Alternative: Add delay between requests const fetchWithDelay = async (url: string) => { await new Promise(resolve => setTimeout(resolve, 100)) // 100ms delay return fetch(url).then(r => r.json()) } ``` ### Pattern 2: Memory-Constrained Processing **Problem**: Process 1000 large files, but only 2GB RAM available. ```typescript import { mapAsync } from '@ontologyio/receta/async' const processLargeFiles = async (files: string[]) => { // Each file uses ~500MB when processing // 2GB / 500MB = 4 files max return mapAsync( files, async (file) => { const data = await readLargeFile(file) // 500MB const processed = await processData(data) await writeOutput(processed) return processed }, { concurrency: 3 } // Leave some RAM headroom ) } ``` ### Pattern 3: Batch Processing with Progress **Problem**: Process 10,000 items and show progress. ```typescript const processBatch = async ( items: T[], fn: (item: T) => Promise, onProgress: (completed: number, total: number) => void ) => { let completed = 0 const results = await mapAsync( items, async (item) => { const result = await fn(item) completed++ onProgress(completed, items.length) return result }, { concurrency: 20 } ) return results } // Usage await processBatch( users, updateUser, (completed, total) => { console.log(`Progress: ${completed}/${total} (${(completed/total*100).toFixed(1)}%)`) } ) ``` ### Pattern 4: Retry with Exponential Backoff **Problem**: API calls sometimes fail - retry with increasing delays. ```typescript import { mapAsync, retry } from '@ontologyio/receta/async' const fetchWithRetry = async (urls: string[]) => { return mapAsync( urls, (url) => retry(() => fetch(url).then(r => r.json()), { maxAttempts: 4, delay: 1000, // 1s, 2s, 4s backoff: 2, onRetry: (_error, attempt, delay) => { console.log(`Retry ${attempt}/3 after ${delay}ms`) } }), { concurrency: 10 } ) } ``` ### Pattern 5: Priority Queue Processing **Problem**: Process high-priority items first, then low-priority. ```typescript type Task = { id: string priority: 'high' | 'low' fn: () => Promise } const processByPriority = async (tasks: Task[]) => { const high = tasks.filter(t => t.priority === 'high') const low = tasks.filter(t => t.priority === 'low') // Process high priority first (parallel) await mapAsync(high, t => t.fn(), { concurrency: 10 }) // Then process low priority await mapAsync(low, t => t.fn(), { concurrency: 5 }) } ``` --- ## Comparison Table | Function | Execution | Use Case | Concurrency Control | Error Handling | |----------|-----------|----------|---------------------|----------------| | `mapAsync` | Parallel with limit | Transform many items | βœ… Yes (configurable) | `Result` | | `filterAsync` | Parallel with limit | Filter with async check | βœ… Yes (configurable) | `Result` | | `parallel` | All at once, or with limit | Independent tasks | βœ… Yes (configurable) | `Result` | | `sequential` | One by one | Dependent tasks | ❌ No (always sequential) | `Result` | ### When to Use Each ```typescript // mapAsync - Transform with rate limit const results = await mapAsync(items, fetchData, { concurrency: 10 }) // filterAsync - Filter with async validation const valid = await filterAsync(items, isValid, { concurrency: 20 }) // parallel - Load independent data const loaded = await parallel([ fetchUsers, fetchProducts, fetchOrders ]) // sequential - Steps depend on each other const steps = await sequential([ authenticate, fetchData, processData ]) ``` --- ## Common Pitfalls and Solutions ### Pitfall 1: Too High Concurrency ```typescript // ❌ Bad - Will hit rate limit await mapAsync(repos, fetchRepo) // Default: unlimited! // βœ… Good - Respect rate limit await mapAsync(repos, fetchRepo, { concurrency: 10 }) ``` ### Pitfall 2: Sequential When Should Parallel ```typescript // ❌ Bad - Slow (3 seconds total) const a = await fetchA() // 1 second const b = await fetchB() // 1 second const c = await fetchC() // 1 second // βœ… Good - Fast (1 second total) const results = await parallel([fetchA, fetchB, fetchC]) ``` ### Pitfall 3: Parallel When Should Sequential ```typescript // ❌ Bad - Might capture before authorization completes! const results = await parallel([ authorizeCard, capturePayment ]) // βœ… Good - Capture only after authorization const results = await sequential([ authorizeCard, capturePayment ]) ``` ### Pitfall 4: Not Handling Errors ```typescript // ❌ Bad - One failure stops everything await mapAsync(items, processItem) // βœ… Good - Each item gets its own Result await mapAsync(items, (item) => tryCatchAsync(() => processItem(item))) ``` ### Pitfall 5: Memory Leak in Long-Running Tasks ```typescript // ❌ Bad - Loads all files into memory at once const files = await Promise.all(filePaths.map(readFile)) // βœ… Good - Process in batches const processInBatches = async (items: string[], batchSize = 100) => { for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize) await mapAsync(batch, processFile, { concurrency: 10 }) } } ``` --- ## Performance Guidelines ### Choosing the Right Concurrency Limit ```typescript // API Rate Limits const concurrency = { github: 10, // 5000/hour = ~1.4/second, 10 is safe stripe: 50, // 100/second, 50 is safe twitter: 5, // Strict limits internal: 100 // Your own API, can be higher } // System Resources const concurrency = { imageProcessing: 4, // CPU-intensive fileIO: 20, // I/O bound database: 15, // Based on connection pool size network: 50 // Network I/O } // Rule of thumb const safeConcurrency = Math.min( rateLimit / 2, // Half of rate limit connectionPoolSize - 5, // Leave connections for other tasks availableRAM / taskMemory // Don't exceed RAM ) ``` ### Benchmarking Your Operations ```typescript const benchmark = async () => { const items = Array.from({ length: 100 }, (_, i) => i) const concurrencies = [1, 5, 10, 20, 50, 100] for (const concurrency of concurrencies) { const start = Date.now() await mapAsync( items, async (item) => { await new Promise(resolve => setTimeout(resolve, 100)) return item * 2 }, { concurrency } ) const duration = Date.now() - start console.log(`Concurrency ${concurrency}: ${duration}ms`) } } // Results: // Concurrency 1: 10,000ms // Concurrency 5: 2,000ms // Concurrency 10: 1,000ms ← Sweet spot // Concurrency 20: 500ms // Concurrency 50: 200ms // Concurrency 100: 100ms ← Diminishing returns ``` ### Memory Usage Monitoring ```typescript const monitorMemory = async () => { const before = process.memoryUsage() await mapAsync( largeDataset, processItem, { concurrency: 10 } ) const after = process.memoryUsage() const diff = after.heapUsed - before.heapUsed console.log(`Memory used: ${(diff / 1024 / 1024).toFixed(2)}MB`) // If too high, reduce concurrency } ``` ### Optimal Concurrency Formula ```typescript const calculateOptimalConcurrency = (params: { rateLimit?: number // Requests per second allowed taskDuration?: number // Average task duration in ms memoryPerTask?: number // MB per task availableMemory?: number // Total MB available poolSize?: number // Connection pool size }) => { const limits = [ params.rateLimit ? params.rateLimit * 0.8 : Infinity, params.availableMemory && params.memoryPerTask ? Math.floor(params.availableMemory / params.memoryPerTask) : Infinity, params.poolSize ? params.poolSize * 0.8 : Infinity, 100 // Never go above 100 ] return Math.floor(Math.min(...limits)) } // Example usage const concurrency = calculateOptimalConcurrency({ rateLimit: 100, // 100 req/sec memoryPerTask: 50, // 50MB per task availableMemory: 1000, // 1GB available poolSize: 20 // 20 connections }) console.log(`Optimal concurrency: ${concurrency}`) // Output: 16 (limited by poolSize: 20 * 0.8 = 16) ``` --- ## Real-World Example: E-Commerce Order Processing Putting it all together - processing orders with multiple async steps: ```typescript import { mapAsync, sequential, parallel } from '@ontologyio/receta/async' type Order = { id: string items: Array<{ productId: string; quantity: number }> customerId: string total: number } const processOrder = async (order: Order) => { // Sequential: Must happen in order const steps = await sequential([ // Step 1: Validate inventory async () => { console.log(`Validating order ${order.id}`) const inStock = await validateInventory(order.items) if (!inStock) throw new Error('Out of stock') return true }, // Step 2: Charge customer (only after validation!) async () => { console.log(`Charging customer for order ${order.id}`) return stripe.charges.create({ customer: order.customerId, amount: order.total }) }, // Step 3: Fulfill order (only after payment!) async () => { console.log(`Fulfilling order ${order.id}`) // Parallel: These can happen at the same time const fulfilled = await parallel([ () => createShipment(order), () => generateReceipt(order), () => sendOrderConfirmation(order.customerId) ]) // Throw so sequential sees the failure and stops if (isErr(fulfilled)) throw fulfilled.error const [shipment, receipt, notification] = fulfilled.value return { shipment, receipt, notification } } ]) // Throw so mapAsync sees the failure if (isErr(steps)) throw steps.error const [validated, charged, fulfilled] = steps.value return { validated, charged, fulfilled } } const processOrders = async (orders: Order[]) => { // Process multiple orders concurrently (but each order is sequential internally) return mapAsync( orders, processOrder, { concurrency: 10 } // Process 10 orders at once ) } // Usage const orders: Order[] = [/* ... 100 orders ... */] const results = await processOrders(orders) if (isOk(results)) { console.log(`Processed ${results.value.length} orders`) } ``` --- ## Next Steps - **[Retry and Timeout](./retry-timeout.mdx)**: Retry logic with exponential backoff, and timeouts - **[Patterns](./patterns.mdx)**: Recipes that combine these with Result - **[API Reference](../reference/async.mdx)**: Complete async module API --- # Debounce and Throttle > Control function execution frequency for performance and better UX ## Overview Debounce and throttle are essential techniques for controlling how often functions execute in response to high-frequency events. They prevent performance issues and reduce unnecessary API calls while maintaining responsive user experiences. **Key differences:** - **Debounce**: Wait for a pause in events before executing (last call wins) - **Throttle**: Execute at most once per delay (the first call runs, then the latest) Both patterns are critical for production applications dealing with user input, scroll events, window resizing, and API rate limits. --- ## Debounce ### What is Debouncing? Debouncing delays function execution until a specified time has passed since the last invocation. It's like waiting for someone to finish typing before acting on their input. **Mental Model:** ``` Events: a---b-c-d-----e-f-g-h---------i------ Debounce: ^ ^ (500ms) Execute Execute ``` The function only executes when events stop arriving for the specified delay period. ### Leading vs Trailing Edge ```typescript import { debounce } from '@ontologyio/receta/async' // Trailing edge (default) - execute AFTER silence const searchTrailing = debounce( async (query: string) => { return searchAPI(query) }, { delay: 300 } ) // Leading edge - execute IMMEDIATELY on first call, then silence const searchLeading = debounce( async (query: string) => { return searchAPI(query) }, { delay: 300, leading: true, trailing: false } ) // Both edges - execute on first AND last const searchBoth = debounce( async (query: string) => { return searchAPI(query) }, { delay: 300, leading: true, trailing: true } ) ``` **Visual comparison:** ``` Input: a-b-c-d------e-f-g----------h-i-j-k---- Trailing: ^ ^ ^ Leading: ^ ^ ^ Both: ^ ^ ^ ^ ^ ``` **Replaced calls share the run that replaced them.** Every call settles: calls waiting for the same run get one shared promise, so they all resolve with its result, or reject if it fails. With `trailing: false`, a call ignored during the delay gets the last leading result. If `leading` and `trailing` are both false, nothing can run, so calls reject. ### Real-World Examples #### 1. Search Autocomplete (Algolia-style) ```tsx import { debounce } from '@ontologyio/receta/async' import { match, ok, tryCatchAsync, type Result } from '@ontologyio/receta/result' interface SearchResult { id: string title: string description: string score: number } // Debounced search prevents API spam while typing const debouncedSearch = debounce( async (query: string): Promise> => { if (query.trim().length < 2) { return ok([]) } return tryCatchAsync( async () => { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`) if (!response.ok) throw new Error(`Search failed: ${response.statusText}`) return response.json() }, (error) => `Search error: ${error}` ) }, { delay: 300 } ) // React/Vue component usage function SearchBox() { const [query, setQuery] = useState('') const [results, setResults] = useState([] as SearchResult[]) const [loading, setLoading] = useState(false) const handleSearch = async (value: string) => { setQuery(value) setLoading(true) const result = await debouncedSearch(value) match(result, { onOk: (data) => { setResults(data) setLoading(false) }, onErr: (error) => { console.error(error) setResults([]) setLoading(false) } }) } return ( handleSearch(e.target.value)} placeholder="Search..." /> ) } ``` **Why 300ms?** Research shows users perceive responses under 300ms as instantaneous. This delay strikes a balance between responsiveness and reducing API calls. #### 2. Form Auto-Save (Google Docs-style) ```typescript import { debounce } from '@ontologyio/receta/async' import { match, tryCatchAsync, type Result } from '@ontologyio/receta/result' interface Document { id: string content: string lastSaved: Date } interface AutoSaveOptions { onSaving?: () => void onSaved?: (doc: Document) => void onError?: (error: string) => void } function createAutoSave( documentId: string, options: AutoSaveOptions = {} ) { let latestContent = '' const persist = async (content: string): Promise> => { options.onSaving?.() return tryCatchAsync( async () => { const response = await fetch(`/api/documents/${documentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }) if (!response.ok) { throw new Error(`Save failed: ${response.statusText}`) } return response.json() }, (error) => `Failed to save document: ${error}` ) } // Debounce with 2 second delay - wait for user to pause typing const saveDocument = debounce(persist, { delay: 2000 }) return { save: async (content: string) => { latestContent = content const result = await saveDocument(content) match(result, { onOk: (doc) => options.onSaved?.(doc), onErr: (error) => options.onError?.(error) }) }, // Save right away (e.g., on window close) // debounce has no flush, so this skips it and calls persist directly saveNow: () => persist(latestContent) } } // Usage const autoSave = createAutoSave('doc-123', { onSaving: () => console.log('Saving...'), onSaved: (doc) => console.log('Saved at', doc.lastSaved), onError: (err) => console.error(err) }) // User types - auto-saves after 2s of inactivity editor.onChange((content) => autoSave.save(content)) // User closes window - save immediately window.addEventListener('beforeunload', () => autoSave.saveNow()) ``` #### 3. API Call Optimization ```typescript import { debounce } from '@ontologyio/receta/async' import { tryCatchAsync, type Result } from '@ontologyio/receta/result' // Prevent duplicate API calls when user rapidly changes filters interface ProductFilters { category?: string priceMin?: number priceMax?: number inStock?: boolean } const fetchProducts = debounce( async (filters: ProductFilters): Promise> => { const params = new URLSearchParams() if (filters.category) params.set('category', filters.category) if (filters.priceMin) params.set('price_min', String(filters.priceMin)) if (filters.priceMax) params.set('price_max', String(filters.priceMax)) if (filters.inStock) params.set('in_stock', 'true') return tryCatchAsync( async () => { const response = await fetch(`/api/products?${params}`) if (!response.ok) throw new Error('Failed to fetch products') return response.json() }, (error) => `Product fetch failed: ${error}` ) }, { delay: 500 } ) // User adjusts multiple filters rapidly // Only makes ONE API call 500ms after they stop updateFilter('category', 'electronics') // Scheduled updateFilter('priceMax', 1000) // Cancels previous, reschedules updateFilter('inStock', true) // Cancels previous, reschedules // ... 500ms pause ... // NOW executes with all filters ``` --- ## Throttle ### What is Throttling? Throttling ensures a function executes at most once per specified time interval. It's like a rate limiter that guarantees regular execution during continuous events. **Mental Model:** ``` Events: a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p Throttle: ^ ^ ^ ^ ^ (200ms) Exec Exec Exec Exec Exec ``` Unlike debounce, throttle executes periodically during ongoing events. ### Leading vs Trailing Edge ```typescript import { throttle } from '@ontologyio/receta/async' // Both edges (default) - execute FIRST call immediately, then LAST call in the delay const trackScrollBoth = throttle( async (position: number) => { analytics.track('scroll', { position }) }, { delay: 1000 } ) // Leading edge - execute FIRST call immediately const trackScrollLeading = throttle( async (position: number) => { analytics.track('scroll', { position }) }, { delay: 1000, trailing: false } ) // Trailing edge - execute LAST call in the delay const trackScrollTrailing = throttle( async (position: number) => { analytics.track('scroll', { position }) }, { delay: 1000, leading: false } ) ``` **Visual comparison:** ``` Input: a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p Leading: ^ ^ ^ ^ ^ Trailing: ^ ^ ^ ^ Both: ^ ^ ^ ^ ^ ^ ^ ^ ^ ``` Calls made during the delay don't queue up. They share one promise for the next trailing run, which uses the latest arguments, so earlier arguments are dropped. ### Real-World Examples #### 1. Scroll Event Tracking (Analytics) ```typescript import { throttle } from '@ontologyio/receta/async' interface ScrollMetrics { scrollY: number scrollPercent: number viewportHeight: number documentHeight: number } // Track scroll position for analytics (Segment/Mixpanel style) const trackScrollPosition = throttle( async (metrics: ScrollMetrics) => { // Send to analytics only once per second window.analytics?.track('Page Scrolled', { scroll_depth: metrics.scrollPercent, position_y: metrics.scrollY, viewport_height: metrics.viewportHeight, document_height: metrics.documentHeight }) }, { delay: 1000 } ) window.addEventListener('scroll', () => { const scrollY = window.scrollY const viewportHeight = window.innerHeight const documentHeight = document.documentElement.scrollHeight const scrollPercent = Math.round((scrollY / (documentHeight - viewportHeight)) * 100) trackScrollPosition({ scrollY, scrollPercent, viewportHeight, documentHeight }) }) ``` #### 2. Progress Updates (Real-time Dashboards) ```typescript import { throttle } from '@ontologyio/receta/async' interface UploadProgress { loaded: number total: number percent: number } // Update UI only every 100ms to prevent excessive re-renders const updateProgressUI = throttle( async (progress: UploadProgress) => { const bar = document.querySelector('.progress-bar') const text = document.querySelector('.progress-text') // Update progress bar if (bar) bar.style.width = `${progress.percent}%` if (text) text.textContent = `${progress.loaded.toLocaleString()} / ${progress.total.toLocaleString()} bytes` }, { delay: 100 } ) async function uploadFile(file: File) { const formData = new FormData() formData.append('file', file) const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { updateProgressUI({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) }) } }) return new Promise((resolve, reject) => { xhr.addEventListener('load', () => resolve(xhr.response)) xhr.addEventListener('error', () => reject(new Error('Upload failed'))) xhr.open('POST', '/api/upload') xhr.send(formData) }) } ``` #### 3. Rate-Limited Webhooks ```typescript import { throttle } from '@ontologyio/receta/async' import { tryCatchAsync, type Result } from '@ontologyio/receta/result' interface WebhookPayload { event: string data: Record timestamp: Date } // Send webhooks at most once per 5 seconds to respect rate limits const sendWebhook = throttle( async (payload: WebhookPayload): Promise> => { return tryCatchAsync( async () => { const response = await fetch(process.env.WEBHOOK_URL!, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Secret': process.env.WEBHOOK_SECRET! }, body: JSON.stringify(payload) }) if (!response.ok) { throw new Error(`Webhook failed: ${response.statusText}`) } }, (error) => `Webhook delivery failed: ${error}` ) }, { delay: 5000 } ) // Events fire rapidly, but webhook sends at most every 5s // Events in between are dropped, so use this only when the latest one is enough eventEmitter.on('user.updated', (user) => { sendWebhook({ event: 'user.updated', data: { userId: user.id, email: user.email }, timestamp: new Date() }) }) ``` #### 4. Window Resize Handling ```typescript import { throttle } from '@ontologyio/receta/async' interface ViewportSize { width: number height: number breakpoint: 'mobile' | 'tablet' | 'desktop' } const getBreakpoint = (width: number): ViewportSize['breakpoint'] => { if (width < 768) return 'mobile' if (width < 1024) return 'tablet' return 'desktop' } // Recalculate layout only once per 200ms during resize const handleResize = throttle( async () => { const viewport: ViewportSize = { width: window.innerWidth, height: window.innerHeight, breakpoint: getBreakpoint(window.innerWidth) } // Update responsive components store.dispatch({ type: 'viewport/update', payload: viewport }) // Re-render charts, grids, etc. chartManager.resize(viewport) }, { delay: 200 } ) window.addEventListener('resize', handleResize) ``` --- ## Debounce vs Throttle Comparison ### Side-by-Side Behavior ``` User Action: Typing "hello world" Timeline: h-e-l-l-o- -w-o-r-l-d-------- Debounce (300ms): Cancel each time until pause ^ Execute (after typing stops) Throttle (300ms): ^ ^ ^ Execute periodically ``` ### Visual Comparison ``` Event Stream: ●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●-------- (continuous events) (silence) DEBOUNCE (500ms): wait wait wait wait wait EXECUTE! β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ "Let me finish first" THROTTLE (500ms): EXEC EXEC EXEC β””β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ β””β”€β”€β”€β”˜ "Regular updates please" ``` ### Feature Comparison Table | Feature | Debounce | Throttle | |---------|----------|----------| | **Execution** | After silence | During activity | | **Frequency** | Once after pause | Regular intervals | | **Use Case** | Wait for completion | Rate limiting | | **Example** | Search input | Scroll tracking | | **User feels** | Responsive after action | Continuous feedback | | **API calls** | Minimized drastically | Controlled rate | | **Best for** | Intentional actions | Continuous events | ### When to Use Which #### Use Debounce When: βœ… User needs to "finish" an action (typing, dragging, resizing) βœ… You want to wait for a pause before responding βœ… Final state is what matters, not intermediate states βœ… Examples: search, auto-save, form validation #### Use Throttle When: βœ… Continuous events need periodic sampling βœ… You want guaranteed execution during activity βœ… Intermediate states matter for feedback βœ… Examples: scroll tracking, progress updates, analytics --- ## Real-World Patterns ### Pattern 1: Search with Loading States ```typescript import { debounce } from '@ontologyio/receta/async' import { match, ok, tryCatchAsync, type Result } from '@ontologyio/receta/result' interface SearchState { query: string results: SearchResult[] loading: boolean error: string | null } const createSearchManager = () => { let state: SearchState = { query: '', results: [], loading: false, error: null } const performSearch = debounce( async (query: string): Promise> => { // Don't search empty queries if (query.trim().length === 0) { return ok([]) } const result = await tryCatchAsync( async () => { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`) if (!response.ok) throw new Error('Search failed') return response.json() }, (error) => String(error) ) return result }, { delay: 300 } ) return { search: async (query: string) => { state.query = query state.loading = true state.error = null updateUI(state) const result = await performSearch(query) match(result, { onOk: (results) => { state.results = results state.loading = false updateUI(state) }, onErr: (error) => { state.error = error state.loading = false state.results = [] updateUI(state) } }) } } } ``` ### Pattern 2: Form Auto-Save with Conflict Detection ```typescript import { debounce } from '@ontologyio/receta/async' import { match, tryCatchAsync, type Result } from '@ontologyio/receta/result' interface DocumentVersion { id: string content: string version: number lastModified: Date } const createAutoSaveManager = (documentId: string) => { let currentVersion = 0 const persist = async ( content: string, expectedVersion: number ): Promise> => { return tryCatchAsync( async () => { const response = await fetch(`/api/documents/${documentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, version: expectedVersion }) }) if (response.status === 409) { throw new Error('Document was modified by another user') } if (!response.ok) { throw new Error('Save failed') } const saved: DocumentVersion = await response.json() currentVersion = saved.version return saved }, (error) => String(error) ) } const saveDocument = debounce(persist, { delay: 2000 }) return { save: async (content: string) => { const result = await saveDocument(content, currentVersion) return match(result, { onOk: (doc) => ({ success: true, message: `Saved at ${doc.lastModified.toLocaleTimeString()}` }), onErr: (error) => ({ success: false, message: error }) }) }, saveNow: (content: string) => persist(content, currentVersion) } } ``` ### Pattern 3: Infinite Scroll with Throttled Loading ```typescript import { throttle } from '@ontologyio/receta/async' import { match, type Result } from '@ontologyio/receta/result' interface PaginatedResponse { items: T[] nextCursor: string | null hasMore: boolean } const createInfiniteScroll = ( fetchPage: (cursor: string | null) => Promise, string>> ) => { let loading = false let cursor: string | null = null let hasMore = true const checkAndLoad = throttle( async () => { // Don't load if already loading or no more data if (loading || !hasMore) return // Check if near bottom (within 200px) const scrollBottom = window.scrollY + window.innerHeight const documentHeight = document.documentElement.scrollHeight if (documentHeight - scrollBottom < 200) { loading = true const result = await fetchPage(cursor) match(result, { onOk: (response) => { // Append new items to DOM renderItems(response.items) cursor = response.nextCursor hasMore = response.hasMore loading = false }, onErr: (error) => { console.error('Failed to load more:', error) loading = false } }) } }, { delay: 200 } ) window.addEventListener('scroll', checkAndLoad) return { destroy: () => window.removeEventListener('scroll', checkAndLoad) } } ``` --- ## Performance Impact ### Debounce Performance Benefits ```typescript // WITHOUT debounce // User types "macbook pro 16" (15 characters) // = 15 API calls = ~750ms total time input.addEventListener('input', (e) => { searchAPI(e.target.value) // Fires 15 times! }) // WITH debounce (300ms) // User types "macbook pro 16" (15 characters) // = 1 API call = ~50ms total time const debouncedSearch = debounce(searchAPI, { delay: 300 }) input.addEventListener('input', (e) => { debouncedSearch(e.target.value) // Fires once! }) // Savings: 93% fewer API calls, 10x faster ``` ### Throttle Performance Benefits ```typescript // WITHOUT throttle // User scrolls 1000px in 2 seconds // Scroll fires ~200 times per second // = 400 analytics events window.addEventListener('scroll', () => { trackScrollPosition() // Fires 400 times! }) // WITH throttle (100ms) // User scrolls 1000px in 2 seconds // = 20 analytics events (one per 100ms) const throttledTracker = throttle(trackScrollPosition, { delay: 100 }) window.addEventListener('scroll', throttledTracker) // Fires 20 times // Savings: 95% fewer events, much smoother UI ``` ### Memory and CPU Impact ```tsx import { debounce, throttle } from '@ontologyio/receta/async' // BAD: Creates new debounced function on every render function Component() { const handleSearch = debounce(async (q: string) => search(q), { delay: 300 }) // ❌ New instance each render = memory leak } // GOOD: Stable reference function Component() { const handleSearch = useMemo( () => debounce(async (q: string) => search(q), { delay: 300 }), [] ) // βœ… Single instance = efficient } // Or with a ref function Component() { const handleSearchRef = useRef( debounce(async (q: string) => search(q), { delay: 300 }) ) return handleSearchRef.current(e.target.value)} /> } ``` --- ## Best Practices ### 1. Choose Appropriate Delays ```typescript // Search input: 200-400ms (feels instant, reduces API calls) const search = debounce(searchAPI, { delay: 300 }) // Auto-save: 1-3s (balance between data loss and API load) const autoSave = debounce(saveDocument, { delay: 2000 }) // Scroll tracking: 100-200ms (smooth but not excessive) const trackScroll = throttle(analytics.track, { delay: 100 }) // Window resize: 100-200ms (responsive layout updates) const handleResize = throttle(updateLayout, { delay: 150 }) // Analytics: 500-1000ms (batch events, reduce data volume) const trackEvent = throttle(sendAnalytics, { delay: 1000 }) ``` ### 2. Provide User Feedback ```typescript import { match } from '@ontologyio/receta/result' import { debounce } from '@ontologyio/receta/async' const searchWithFeedback = debounce( async (query: string) => { // Show searching state setSearching(true) const result = await searchAPI(query) // Show results or error match(result, { onOk: (data) => { setResults(data) setSearching(false) }, onErr: (error) => { setError(error) setSearching(false) } }) }, { delay: 300 } ) // UI shows: "Searching..." β†’ "Found 42 results" or "No results" ``` ### 3. Handle Cleanup Properly `debounce` and `throttle` return a plain function, with no `cancel` or `flush`. A pending call still runs after the component is gone, so ignore its result: ```typescript import { debounce } from '@ontologyio/receta/async' // React example function useSearch() { const mounted = useRef(true) useEffect(() => { mounted.current = true return () => { mounted.current = false } }, []) return useMemo( () => debounce( async (query: string) => { const results = await search(query) if (mounted.current) setResults(results) }, { delay: 300 } ), [] ) } ``` --- ## Summary ### Debounce - **Purpose**: Wait for user to finish before acting - **Best for**: Search, auto-save, form validation, API calls - **Typical delays**: 200-400ms (input), 1-3s (save) - **Mental model**: "Let them finish typing first" ### Throttle - **Purpose**: Limit execution rate during continuous events - **Best for**: Scroll, resize, progress, analytics - **Typical intervals**: 100-200ms (UI), 500-1000ms (analytics) - **Mental model**: "Regular updates, not overwhelming" ### Key Takeaway Both patterns are essential tools for production applications. Use debounce when the final value matters, use throttle when you need regular updates during continuous activity. Always consider user experience and API load when choosing delays and intervals. --- # Migration Guide From Manual Async to Async Utils > Step-by-step guide to refactoring manual Promise handling to structured async utilities. ## Why Migrate? **Before (manual async code):** - `Promise.all()` has no concurrency control - Retry logic scattered and inconsistent - No built-in timeout handling - Manual batching is error-prone - Race conditions in debounce/throttle implementations - Hard to test and reason about **After (async utils):** - Concurrency control prevents overwhelming APIs - Consistent retry with exponential backoff - Built-in timeout protection - Reliable batching for bulk operations - Battle-tested debounce/throttle - Composable and testable --- ## Step 1: Replace Promise.all with mapAsync ### Problem: No Concurrency Control **Before:** ```typescript // ❌ Fetches ALL users simultaneously // Can overwhelm the API, database, or network async function fetchAllUsers(userIds: string[]) { return Promise.all( userIds.map(id => fetch(`/api/users/${id}`).then(r => r.json()) ) ) } // With 1000 user IDs, this creates 1000 simultaneous requests! const users = await fetchAllUsers(thousandIds) ``` **After:** ```typescript // βœ… Controlled concurrency - max 5 requests at a time import { mapAsync } from '@ontologyio/receta/async' async function fetchAllUsers(userIds: string[]) { return mapAsync( userIds, async (id) => { const res = await fetch(`/api/users/${id}`) return res.json() }, { concurrency: 5 } ) } // Processes 1000 IDs efficiently with only 5 concurrent requests // The result is a Result: isOk(users) gives you users.value const users = await fetchAllUsers(thousandIds) ``` ### Real-World Example: Stripe Payment Intents **Before:** ```typescript // Express route - processes refunds app.post('/api/bulk-refund', async (req, res) => { const { paymentIntentIds } = req.body try { // ❌ Stripe rate limits at ~100 req/sec // This will fail with 429 errors const refunds = await Promise.all( paymentIntentIds.map(id => stripe.refunds.create({ payment_intent: id }) ) ) res.json({ success: true, refunds }) } catch (error) { res.status(500).json({ error: error.message }) } }) ``` **After:** ```typescript import { mapAsync } from '@ontologyio/receta/async' import { match } from '@ontologyio/receta/result' app.post('/api/bulk-refund', async (req, res) => { const { paymentIntentIds } = req.body // βœ… Respects Stripe rate limits const refunds = await mapAsync( paymentIntentIds, async (id) => stripe.refunds.create({ payment_intent: id }), { concurrency: 10 } // Safe for Stripe API ) match(refunds, { onOk: (value) => res.json({ success: true, refunds: value }), onErr: (error) => res.status(500).json({ error: String(error.error) }) }) }) ``` --- ## Step 2: Add Retry to Unreliable Operations ### Problem: Manual Retry is Fragile **Before:** ```typescript // ❌ Inconsistent retry logic, no backoff async function fetchWithRetry(url: string, maxRetries = 3) { let lastError for (let i = 0; i < maxRetries; i++) { try { const res = await fetch(url) if (res.ok) return res.json() throw new Error(`HTTP ${res.status}`) } catch (error) { lastError = error // Simple delay - same every time await new Promise(resolve => setTimeout(resolve, 1000)) } } throw lastError } ``` **After:** ```typescript // βœ… Exponential backoff, configurable, logs retries import { retry } from '@ontologyio/receta/async' async function fetchWithRetry(url: string) { return retry( async () => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3, delay: 1000, backoff: 2, // Exponential: 1s, 2s, 4s onRetry: (error, attempt, delay) => { console.log(`Retry ${attempt} after ${delay}ms:`, (error as Error).message) } } ) } ``` ### Real-World Example: GitHub API with Rate Limiting **Before:** ```typescript // ❌ No retry logic - fails on rate limit async function fetchGitHubRepo(owner: string, repo: string) { const res = await fetch( `https://api.github.com/repos/${owner}/${repo}` ) if (res.status === 403) { // GitHub rate limit - should retry after delay throw new Error('Rate limited') } return res.json() } // Fails immediately on rate limit const repo = await fetchGitHubRepo('facebook', 'react') ``` **After:** ```typescript import { retry } from '@ontologyio/receta/async' async function fetchGitHubRepo(owner: string, repo: string) { return retry( async () => { const res = await fetch( `https://api.github.com/repos/${owner}/${repo}` ) if (res.status === 403) { const resetTime = res.headers.get('X-RateLimit-Reset') throw new Error(`Rate limited until ${resetTime}`) } if (!res.ok) { throw new Error(`HTTP ${res.status}`) } return res.json() }, { maxAttempts: 5, delay: 1000, backoff: 2, shouldRetry: (error, attempt) => { // Only retry rate limits and network errors const { message } = error as Error return message.includes('Rate limited') || message.includes('fetch failed') }, onRetry: (error, attempt, delay) => { console.log(`GitHub API retry ${attempt} in ${delay}ms`) } } ) } // Automatically retries on rate limit const repo = await fetchGitHubRepo('facebook', 'react') ``` --- ## Step 3: Add Timeouts to Slow Operations ### Problem: No Timeout Protection **Before:** ```typescript // ❌ Can hang forever if API is slow async function fetchUserData(userId: string) { const user = await fetch(`/api/users/${userId}`).then(r => r.json()) const posts = await fetch(`/api/posts?userId=${userId}`).then(r => r.json()) const comments = await fetch(`/api/comments?userId=${userId}`).then(r => r.json()) return { user, posts, comments } } // If any endpoint hangs, the whole operation hangs ``` **After:** ```typescript // βœ… Timeout protection on each operation import { timeout } from '@ontologyio/receta/async' import { isErr, ok } from '@ontologyio/receta/result' async function fetchUserData(userId: string) { const user = await timeout( fetch(`/api/users/${userId}`).then(r => r.json()), 5000 // 5 second timeout ) if (isErr(user)) return user const posts = await timeout( fetch(`/api/posts?userId=${userId}`).then(r => r.json()), 5000 ) if (isErr(posts)) return posts const comments = await timeout( fetch(`/api/comments?userId=${userId}`).then(r => r.json()), 5000 ) if (isErr(comments)) return comments return ok({ user: user.value, posts: posts.value, comments: comments.value }) } // Fails fast if any endpoint is slow ``` ### Real-World Example: Next.js API Route with External Service **Before:** ```typescript // pages/api/weather.ts // ❌ Can block for minutes if weather API is down export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { const { city } = req.query // No timeout - can hang indefinitely const weather = await fetch( `https://api.weather.com/v1/current?city=${city}` ).then(r => r.json()) res.json(weather) } catch (error) { res.status(500).json({ error: error.message }) } } ``` **After:** ```typescript import { timeout, TimeoutError } from '@ontologyio/receta/async' import { flatten, match, tryCatchAsync } from '@ontologyio/receta/result' // pages/api/weather.ts export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { city } = req.query // βœ… 3 second timeout prevents hanging // A timeout is an Err, and tryCatchAsync catches anything fetch rejects with const weather = flatten( await tryCatchAsync(() => timeout( fetch(`https://api.weather.com/v1/current?city=${city}`).then(r => r.json()), 3000 ) ) ) match(weather, { onOk: (value) => res.json(value), onErr: (error) => { if (error instanceof TimeoutError) { res.status(504).json({ error: 'Weather service timeout' }) } else { res.status(500).json({ error: String(error) }) } } }) } ``` --- ## Step 4: Replace Manual Batching with batch() ### Problem: Manual Batching is Error-Prone **Before:** ```typescript // ❌ Complex manual batching logic async function importUsers(users: User[]) { const batchSize = 100 const results: unknown[] = [] for (let i = 0; i < users.length; i += batchSize) { const batch = users.slice(i, i + batchSize) try { const result = await db.users.insertMany(batch) results.push(result) // Manual delay between batches if (i + batchSize < users.length) { await new Promise(resolve => setTimeout(resolve, 1000)) } } catch (error) { console.error(`Batch ${i / batchSize} failed:`, error) throw error } } return results } ``` **After:** ```typescript // βœ… Clean, declarative batching import { batch } from '@ontologyio/receta/async' async function importUsers(users: User[]) { return batch( users, async (userBatch) => db.users.insertMany(userBatch), { batchSize: 100, delayBetweenBatches: 1000, // 1 second between batches } ) } ``` ### Real-World Example: Bulk Email Sending **Before:** ```typescript // ❌ Complex batching with rate limiting async function sendBulkEmails(recipients: string[], emailContent: Email) { const batchSize = 50 const delayMs = 5000 // 5 seconds between batches const results: unknown[] = [] for (let i = 0; i < recipients.length; i += batchSize) { const batch = recipients.slice(i, i + batchSize) console.log(`Sending batch ${i / batchSize + 1}/${Math.ceil(recipients.length / batchSize)}`) try { // Send batch const result = await emailService.sendBatch(batch, emailContent) results.push(result) // Delay between batches to respect rate limits if (i + batchSize < recipients.length) { console.log(`Waiting ${delayMs}ms before next batch...`) await new Promise(resolve => setTimeout(resolve, delayMs)) } } catch (error) { console.error(`Batch failed:`, error) // What now? Partial failure handling is complex } } return results } ``` **After:** ```typescript import { batch } from '@ontologyio/receta/async' async function sendBulkEmails(recipients: string[], emailContent: Email) { return batch( recipients, async (recipientBatch) => { console.log(`Sending to ${recipientBatch.length} recipients`) return emailService.sendBatch(recipientBatch, emailContent) }, { batchSize: 50, delayBetweenBatches: 5000, // 5 seconds between batches } ) } // Even better: combine with retry for resilience // Each batch gets its own Result, so one failed batch doesn't stop the rest import { retry } from '@ontologyio/receta/async' async function sendBulkEmailsWithRetry(recipients: string[], emailContent: Email) { return batch( recipients, async (recipientBatch) => { return retry( async () => emailService.sendBatch(recipientBatch, emailContent), { maxAttempts: 3, delay: 2000, onRetry: (error, attempt) => { console.log(`Retrying batch, attempt ${attempt}`) } } ) }, { batchSize: 50, delayBetweenBatches: 5000, } ) } ``` --- ## Step 5: Replace setTimeout Patterns with debounce/throttle ### Problem: Manual Rate Limiting is Buggy **Before:** ```typescript // ❌ Buggy debounce implementation let searchTimeout: NodeJS.Timeout | undefined function onSearchInput(query: string) { // Clear previous timeout if (searchTimeout) { clearTimeout(searchTimeout) } // Set new timeout searchTimeout = setTimeout(async () => { try { const results = await fetch(`/api/search?q=${query}`).then(r => r.json()) displayResults(results) } catch (error) { console.error('Search failed:', error) } }, 300) } // Issues: // - Global state (searchTimeout) // - Hard to test // - Race conditions possible ``` **After:** ```typescript // βœ… Reliable debounce import { debounce } from '@ontologyio/receta/async' const searchAPI = debounce( async (query: string) => { const results = await fetch(`/api/search?q=${query}`).then(r => r.json()) displayResults(results) }, { delay: 300 } ) function onSearchInput(query: string) { searchAPI(query) } ``` ### Real-World Example: React Auto-Save **Before:** ```typescript // ❌ Manual throttle for auto-save let lastSave = 0 let saveTimeout: NodeJS.Timeout | undefined function useAutoSave(documentId: string) { const [content, setContent] = useState('') const save = async (newContent: string) => { const now = Date.now() const timeSinceLastSave = now - lastSave // Throttle: don't save more than once per 5 seconds if (timeSinceLastSave < 5000) { // Schedule a save for later if (saveTimeout) clearTimeout(saveTimeout) saveTimeout = setTimeout(() => { save(newContent) }, 5000 - timeSinceLastSave) return } lastSave = now try { await fetch(`/api/documents/${documentId}`, { method: 'PATCH', body: JSON.stringify({ content: newContent }) }) } catch (error) { console.error('Auto-save failed:', error) } } const onChange = (newContent: string) => { setContent(newContent) save(newContent) } return { content, onChange } } ``` **After:** ```typescript import { throttle } from '@ontologyio/receta/async' function useAutoSave(documentId: string) { const [content, setContent] = useState('') // βœ… Simple, reliable throttle const save = useMemo( () => throttle( async (newContent: string) => { await fetch(`/api/documents/${documentId}`, { method: 'PATCH', body: JSON.stringify({ content: newContent }) }) }, { delay: 5000 } ), [documentId] ) const onChange = (newContent: string) => { setContent(newContent) save(newContent) } return { content, onChange } } ``` --- ## Step 6: Migrate to Result Pattern ### Why Migrate to Result? Result-returning functions provide **type-safe error handling** without exceptions: **Before (exceptions)**: ```typescript async function emailFor(id: string) { try { const data = await fetchUser(id) return data.email } catch (error) { console.error('Failed:', error) return 'fallback@example.com' } } ``` **After (Result)**: ```typescript async function emailFor(id: string) { const result = await retry(() => fetchUser(id)) return pipe( result, map(user => user.email), tapErr(err => console.error('Failed after', err.attempts, 'attempts')), unwrapOr('fallback@example.com') ) } ``` ### Benefits βœ… **Errors in type signatures** - `Result` visible at compile time βœ… **No exceptions** - Pure functional error handling βœ… **Composable** - Chain with `pipe`, `map`, `mapErr`, `flatMap` βœ… **Explicit** - Must handle errors, can't forget βœ… **Better DX** - IDE shows error types ### Migration Path: Throwing Retry Helper β†’ retry **Before**: ```typescript const fetchUser = async (id: string): Promise => { return retryManually( async () => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3 } ) } // Usage requires try-catch try { const user = await fetchUser('123') console.log(user.name) } catch (error) { console.error('Failed to fetch user') } ``` **After**: ```typescript const fetchUserResult = async (id: string): Promise> => { const result = await retry( async () => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3 } ) return pipe( result, mapErr((err): FetchError => ({ type: 'network', message: `Failed after ${err.attempts} attempts`, retryable: true })) ) } // Usage is type-safe, no try-catch const result = await fetchUserResult('123') const name = pipe(result, map(u => u.name), unwrapOr('Unknown')) ``` ### Migration Path: Promise.all β†’ mapAsync **Before**: ```typescript const processUsers = async (userIds: string[]): Promise => { try { return await Promise.all(userIds.map((id) => fetchUser(id))) } catch (error) { console.error('One user failed, entire batch failed') return [] } } ``` **After**: ```typescript const processUsersResult = async (userIds: string[]) => { return mapAsync( userIds, (id) => fetchUserResult(id), { concurrency: 5 } ) } // fetchUserResult returns a Result, so each user carries its own outcome const result = await processUsersResult(userIds) if (isOk(result)) { const [users, failures] = partition(result.value) console.log(`Processed ${users.length} users, ${failures.length} failed`) } ``` ### Migration Path: Hand-Written Timeout β†’ timeout **Before**: ```typescript const fetchWithTimeout = async (url: string) => { try { return await withTimeout(fetch(url), 5000) } catch (error) { if (error instanceof TimeoutError) { return fallbackData } throw error } } ``` **After**: ```typescript const fetchWithTimeoutResult = async (url: string) => { const result = await timeout(fetch(url), 5000) return pipe(result, unwrapOr(fallbackData)) } ``` ### Migration Path: Throwing from poll β†’ Returning a Result **Before**: ```typescript const waitForJob = async (jobId: string) => { try { return await poll( async () => { const status = await checkJobStatus(jobId) if (status.state === 'failed') throw new Error('Job failed') return status.state === 'completed' ? status : null }, { interval: 2000, maxAttempts: 30 } ) } catch (error) { console.error('Job polling failed:', error) throw error } } ``` **After**: ```typescript const waitForJobResult = async (jobId: string) => { const finished = await poll( async () => { const status = await checkJobStatus(jobId) if (status.state === 'failed') return err({ type: 'job_failed', jobId }) return status.state === 'completed' ? ok(status) : null }, { interval: 2000, maxAttempts: 30 } ) return flatten(finished) } // Usage const result = await waitForJobResult('job-123') pipe( result, match({ onOk: (status) => console.log('Job completed:', status), onErr: (error) => console.error('Job failed:', error.type) }) ) ``` ### Real-World Example: Payment Processing with Result **Before (exception-based)**: ```typescript async function processPayment(paymentId: string): Promise { try { // Fetch payment details with retry const payment = await retryManually( async () => { const res = await fetch(`/api/payments/${paymentId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3 } ) // Process with timeout const result = await withTimeout( stripe.charges.create({ amount: payment.amount, currency: payment.currency, source: payment.sourceId }), 10000 ) return result } catch (error) { // Can't distinguish between fetch error, timeout, or Stripe error console.error('Payment processing failed:', error) throw error } } // Usage requires try-catch everywhere try { const payment = await processPayment('pay-123') console.log('Success:', payment.id) } catch (error) { // Generic error handling console.error('Failed:', error) } ``` **After (Result-based)**: ```typescript type PaymentError = | { type: 'fetch_failed'; attempts: number; message: string } | { type: 'timeout'; operation: string } | { type: 'stripe_error'; code: string; message: string } type StripeError = { code?: string; message: string } async function processPaymentResult( paymentId: string ): Promise> { // Fetch payment details with retry const paymentResult = await retry( async () => { const res = await fetch(`/api/payments/${paymentId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3 } ) // Early return if fetch failed if (isErr(paymentResult)) { return err({ type: 'fetch_failed', attempts: paymentResult.error.attempts, message: String(paymentResult.error.lastError) }) } const payment = paymentResult.value // Process with timeout // A timeout is an Err, and tryCatchAsync catches what Stripe rejects with const chargeResult = flatten( await tryCatchAsync(() => timeout( stripe.charges.create({ amount: payment.amount, currency: payment.currency, source: payment.sourceId }), 10000 ) ) ) // Transform timeout or Stripe errors return pipe( chargeResult, mapErr((error): PaymentError => { if (error instanceof TimeoutError) { return { type: 'timeout', operation: 'stripe_charge' } } const { code, message } = error as StripeError return { type: 'stripe_error', code: code || 'unknown', message } }) ) } // Usage is type-safe and explicit const result = await processPaymentResult('pay-123') pipe( result, match({ onOk: (payment) => { console.log('Payment succeeded:', payment.id) // Continue with success flow }, onErr: (error) => { // Type-safe error handling switch (error.type) { case 'fetch_failed': console.error(`Failed to fetch payment after ${error.attempts} attempts`) // Retry with exponential backoff break case 'timeout': console.error(`Stripe charge timed out`) // Notify user to check payment status break case 'stripe_error': console.error(`Stripe error [${error.code}]: ${error.message}`) // Show specific error to user break } } }) ) ``` ### Combining Result with Async Utilities **Pattern: Retry + Result + mapAsync**: ```typescript // Process multiple payments with retry and Result const processPaymentsResult = async (paymentIds: string[]) => { return mapAsync( paymentIds, (id) => processPaymentResult(id), { concurrency: 5 } ) } // Usage const result = await processPaymentsResult(paymentIds) if (isOk(result)) { const [paid, failed] = partition(result.value) console.log(`Successfully processed ${paid.length} payments`) // Each failure says what went wrong failed.forEach((error) => console.error('Payment failed:', error.type)) } ``` ### Migration Checklist for Result Pattern - [ ] Identify functions that return `Promise` and throw exceptions - [ ] Change return type to `Promise>` - [ ] Define custom error types (unions) instead of using `Error` - [ ] Replace throwing retry helpers with `retry()` - [ ] Replace `Promise.all()` with `mapAsync()` where you need a limit - [ ] Replace throwing timeout helpers with `timeout()` - [ ] Replace polling loops with `poll()` - [ ] Replace try-catch blocks with `pipe()` + Result functions - [ ] Use `isOk()`/`isErr()` for type narrowing - [ ] Use `match()` for exhaustive error handling - [ ] Use `unwrapOr()` or `unwrapOrElse()` for default values - [ ] Use `mapErr()` to transform and enrich errors - [ ] Test error paths with type-safe assertions - [ ] Update documentation to reflect Result return types ### When to Use Result Pattern **Use Result when:** - βœ… Operation can fail in predictable ways - βœ… Errors are part of normal control flow - βœ… You want compile-time guarantee that errors are handled - βœ… Errors need to be transformed or enriched - βœ… Multiple operations need to be chained **Stick with exceptions when:** - ❌ Truly exceptional conditions (out of memory, etc.) - ❌ Working with libraries that expect exceptions - ❌ Simple scripts where type safety isn't critical - ❌ Legacy code that's not worth refactoring --- ## Pattern-by-Pattern Migration ### Pattern 1: Parallel Fetch β†’ mapAsync **Before:** ```typescript // Fetch all product details const products = await Promise.all( productIds.map(id => fetchProduct(id)) ) ``` **After:** ```typescript import { mapAsync } from '@ontologyio/receta/async' const products = await mapAsync( productIds, fetchProduct, { concurrency: 10 } ) ``` --- ### Pattern 2: Manual Retry Loop β†’ retry() **Before:** ```typescript let attempts = 0 let success = false let result while (!success && attempts < 3) { try { result = await dangerousOperation() success = true } catch (error) { attempts++ if (attempts >= 3) throw error await sleep(1000 * attempts) // Manual backoff } } ``` **After:** ```typescript import { retry } from '@ontologyio/receta/async' const result = await retry( dangerousOperation, { maxAttempts: 3, delay: 1000, backoff: 2 } ) ``` --- ### Pattern 3: setTimeout β†’ debounce **Before:** ```typescript let timer: NodeJS.Timeout | undefined function handleInput(value: string) { clearTimeout(timer) timer = setTimeout(() => { processInput(value) }, 500) } ``` **After:** ```typescript import { debounce } from '@ontologyio/receta/async' const handleInput = debounce( async (value: string) => processInput(value), { delay: 500 } ) ``` --- ### Pattern 4: Polling Loop β†’ poll() **Before:** ```typescript async function waitForJobComplete(jobId: string) { let attempts = 0 const maxAttempts = 30 while (attempts < maxAttempts) { const status = await checkJobStatus(jobId) if (status.state === 'completed') { return status } if (status.state === 'failed') { throw new Error('Job failed') } attempts++ await sleep(2000) } throw new Error('Job timeout') } ``` **After:** ```typescript import { poll } from '@ontologyio/receta/async' async function waitForJobComplete(jobId: string) { return poll( async () => { const status = await checkJobStatus(jobId) // Throwing stops poll, and it rejects rather than returning an Err if (status.state === 'failed') { throw new Error('Job failed') } return status.state === 'completed' ? status : null }, { interval: 2000, maxAttempts: 30, onPoll: (attempt) => console.log(`Checking job... (${attempt})`) } ) } ``` --- ### Pattern 5: Sequential Batches β†’ batch() **Before:** ```typescript async function processInBatches(items: T[], processor: (item: T) => Promise) { for (let i = 0; i < items.length; i += 10) { const batch = items.slice(i, i + 10) await Promise.all(batch.map(processor)) await sleep(1000) // Delay between batches } } ``` **After:** ```typescript import { batch } from '@ontologyio/receta/async' async function processInBatches(items: T[], processor: (item: T) => Promise) { await batch( items, async (batch) => Promise.all(batch.map(processor)), { batchSize: 10, delayBetweenBatches: 1000 } ) } ``` --- ## Common Pitfalls During Migration ### ❌ Pitfall 1: Forgetting to Set Concurrency ```typescript // BAD - same as Promise.all (unlimited concurrency) const results = await mapAsync(items, fetchData) // GOOD - explicit concurrency limit const results = await mapAsync(items, fetchData, { concurrency: 5 }) ``` ### ❌ Pitfall 2: Using retry() for Non-Transient Errors ```typescript // BAD - retrying validation errors makes no sense const user = await retry( async () => { const data = await fetchUserData() if (!data.email) throw new Error('Email required') return data } ) // GOOD - only retry transient errors const user = await retry( async () => fetchUserData(), { shouldRetry: (error) => { // Only retry network errors, not validation errors return (error as Error).message.includes('fetch failed') } } ) ``` ### ❌ Pitfall 3: Nesting Retries ```typescript // BAD - the inner retry returns a Result, so the outer retry never sees a failure const result = await retry(async () => { return retry(async () => { return fetch('/api/data').then(r => r.json()) }) }) // GOOD - single retry at the right level const result = await retry( async () => fetch('/api/data').then(r => r.json()) ) ``` ### ❌ Pitfall 4: Debounce vs Throttle Confusion ```typescript // BAD - using debounce for scroll events (delays execution until scrolling stops) const trackScroll = debounce( async () => analytics.track('scroll'), { delay: 1000 } ) window.addEventListener('scroll', () => trackScroll()) // Only fires when user stops scrolling for 1 second! // GOOD - use throttle for regular sampling const trackScroll = throttle( async () => analytics.track('scroll'), { delay: 1000 } ) window.addEventListener('scroll', () => trackScroll()) // Fires at most once per second while scrolling ``` ### ❌ Pitfall 5: Ignoring Timeout Errors ```typescript // BAD - a timeout is an Err, so the catch never sees it try { const data = await timeout(fetchData(), 5000) render(data) // data is a Result, not the value } catch (error) { console.error('Failed:', error) } // GOOD - check the Result const data = await timeout(fetchData(), 5000) if (isErr(data)) { // Show user-friendly timeout message console.error('Request timed out - please try again') } else { render(data.value) } ``` --- ## Incremental Migration Strategy ### Phase 1: New Code First Start by using async utils in all new code: ```typescript // New feature - use async utils from the start async function processNewOrders() { return mapAsync( await getNewOrders(), processOrder, { concurrency: 5 } ) } ``` ### Phase 2: Migrate High-Impact Areas Focus on code that causes the most problems: 1. **API routes with external services** β†’ Add `timeout` and `retry` 2. **Bulk operations** β†’ Replace `Promise.all` with `mapAsync` 3. **User-facing search** β†’ Use `debounce` 4. **Analytics tracking** β†’ Use `throttle` 5. **Background jobs** β†’ Use `poll` and `batch` ### Phase 3: Systematic Refactoring Use search patterns to find migration candidates: ```bash # Find Promise.all usage rg "Promise\.all" # Find setTimeout patterns rg "setTimeout" # Find retry loops rg "while.*attempts" # Find polling loops rg "setInterval|while.*check" ``` Then migrate each pattern systematically. --- ## Testing Migrated Code ### Testing mapAsync ```typescript import { describe, it, expect, vi } from 'vitest' import { mapAsync } from '@ontologyio/receta/async' import { isErr, unwrap } from '@ontologyio/receta/result' describe('mapAsync migration', () => { it('processes items with concurrency limit', async () => { const fetchUser = vi.fn(async (id: string) => ({ id, name: `User ${id}` })) const ids = ['1', '2', '3', '4', '5'] const users = unwrap(await mapAsync(ids, fetchUser, { concurrency: 2 })) expect(users).toHaveLength(5) expect(fetchUser).toHaveBeenCalledTimes(5) }) it('handles errors correctly', async () => { const fetchUser = vi.fn(async (id: string) => { if (id === '3') throw new Error('User not found') return { id, name: `User ${id}` } }) const result = await mapAsync(['1', '2', '3'], fetchUser) expect(isErr(result)).toBe(true) }) }) ``` ### Testing retry ```typescript import { describe, it, expect, vi } from 'vitest' import { retry } from '@ontologyio/receta/async' import { isErr, ok } from '@ontologyio/receta/result' describe('retry migration', () => { it('retries on failure', async () => { let attempts = 0 const flaky = vi.fn(async () => { attempts++ if (attempts < 3) throw new Error('Temporary failure') return 'success' }) const result = await retry(flaky, { maxAttempts: 3, delay: 10 }) expect(result).toEqual(ok('success')) expect(flaky).toHaveBeenCalledTimes(3) }) it('respects shouldRetry predicate', async () => { const fn = vi.fn(async () => { throw new Error('FATAL_ERROR') }) const result = await retry(fn, { shouldRetry: (error) => !(error as Error).message.includes('FATAL') }) expect(isErr(result)).toBe(true) expect(fn).toHaveBeenCalledTimes(1) // No retries }) }) ``` ### Testing debounce ```typescript import { describe, it, expect, vi } from 'vitest' import { debounce } from '@ontologyio/receta/async' describe('debounce migration', () => { it('delays execution until calls stop', async () => { const fn = vi.fn(async (value: string) => value) const debounced = debounce(fn, { delay: 100 }) // Rapid calls debounced('a') debounced('b') debounced('c') // Wait for debounce await new Promise(resolve => setTimeout(resolve, 150)) // Only last call should execute expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledWith('c') }) }) ``` --- ## Migration Checklist ### Pre-Migration - [ ] Identify all `Promise.all` usage - [ ] Find manual retry loops - [ ] Locate `setTimeout` debounce/throttle patterns - [ ] Search for manual polling loops - [ ] Find batching logic ### During Migration - [ ] Replace `Promise.all` with `mapAsync` + concurrency - [ ] Replace retry loops with `retry()` - [ ] Add `timeout()` to external API calls - [ ] Replace manual batching with `batch()` - [ ] Replace `setTimeout` patterns with `debounce`/`throttle` - [ ] Replace polling loops with `poll()` ### Post-Migration - [ ] Add tests for new async utilities - [ ] Monitor error rates and timeouts - [ ] Verify concurrency limits are effective - [ ] Check retry backoff behavior in logs - [ ] Confirm debounce/throttle timing - [ ] Update documentation ### Testing - [ ] Test concurrency limits (should not exceed limit) - [ ] Test retry on transient failures - [ ] Test timeout behavior (should fail fast) - [ ] Test debounce (should delay until calls stop) - [ ] Test throttle (should limit call rate) - [ ] Test batch processing (correct batch sizes and delays) --- ## Next Steps - **[Patterns](./patterns.mdx)**: Complete recipes for common scenarios - **[API Reference](../reference/async.mdx)**: Quick function lookup - **[Retry and Timeout](./retry-timeout.mdx)**: Combining utilities for complex workflows --- # Common Patterns & Recipes Real-world patterns for async operations using Receta's async module. All examples are production-ready and use actual APIs. ## Table of Contents - [Result Integration Patterns](#result-integration-patterns) - [API Integration Patterns](#api-integration-patterns) - [Bulk Operations](#bulk-operations) - [Real-time Updates](#real-time-updates) - [Error Recovery](#error-recovery) - [Performance Optimization](#performance-optimization) ## Result Integration Patterns Async functions return a Result for type-safe error handling. This section shows how to build production-ready patterns on that. ### Pattern: Resilient API Client with Result ```typescript import * as R from 'remeda' import { retry, timeout, mapAsync } from '@ontologyio/receta/async' import { mapErr, partition, tapErr, unwrapOr, isOk, isErr } from '@ontologyio/receta/result' import type { Result } from '@ontologyio/receta/result' type APIError = { type: 'network' | 'timeout' | 'rate_limit' | 'not_found' message: string retryable: boolean } class ResilientAPIClient { constructor(private baseURL: string, private apiKey: string) {} private async request( endpoint: string, options?: RequestInit ): Promise> { const result = await retry( async () => { const res = await timeout( fetch(`${this.baseURL}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', ...options?.headers, }, }), 10000 // 10s timeout ) if (isErr(res)) { throw new Error('Timeout') } const response = res.value if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() as Promise }, { maxAttempts: 3, delay: 1000, backoff: 2, shouldRetry: (error, attempt) => { // Only retry network errors, not client errors return error instanceof Error && !error.message.includes('4') }, } ) return R.pipe( result, mapErr((error): APIError => { if (error.lastError instanceof Error) { if (error.lastError.message.includes('Timeout')) { return { type: 'timeout', message: 'Request timed out', retryable: true } } if (error.lastError.message.includes('429')) { return { type: 'rate_limit', message: 'Rate limit exceeded', retryable: true } } if (error.lastError.message.includes('404')) { return { type: 'not_found', message: 'Resource not found', retryable: false } } } return { type: 'network', message: 'Network error', retryable: true } }) ) } async getUser(id: string) { return this.request(`/users/${id}`) } async getUsers(ids: string[]) { return mapAsync( ids, (id) => this.getUser(id), { concurrency: 5 } ) } } // Usage - no try-catch needed! const client = new ResilientAPIClient('https://api.example.com', 'key') const userResult = await client.getUser('123') const user = R.pipe( userResult, tapErr(err => console.error('Failed to fetch user:', err)), unwrapOr({ id: '123', name: 'Unknown' }) ) const usersResult = await client.getUsers(['1', '2', '3']) // getUser returns a Result, so each user carries its own outcome if (isOk(usersResult)) { const [users, failures] = partition(usersResult.value) console.log(`Loaded ${users.length} users, ${failures.length} failed`) } ``` ### Pattern: Batch Processing with Result ```typescript import { batch } from '@ontologyio/receta/async' import { mapErr, partition } from '@ontologyio/receta/result' import type { Result } from '@ontologyio/receta/result' async function batchProcessWithResult( items: T[], processor: (item: T) => Promise>, options: { batchSize: number; delayBetweenBatches: number } ): Promise<{ successes: U[]; failures: Array<{ item: T; error: string }> }> { const results: Array> = [] await batch( items, async (batchItems) => { for (const item of batchItems) { const result = await processor(item) results.push(mapErr(result, error => ({ item, error }))) } }, options ) const [successes, failures] = partition(results) return { successes, failures } } // Usage const { successes, failures } = await batchProcessWithResult( records, async (record) => processRecord(record), { batchSize: 100, delayBetweenBatches: 1000 } ) console.log(`Processed ${successes.length} successfully`) console.log(`Failed ${failures.length}:`, failures) ``` ### Pattern: Parallel Operations with All Success ```typescript import { parallel, type MapAsyncError } from '@ontologyio/receta/async' import { isOk, map } from '@ontologyio/receta/result' import type { Result } from '@ontologyio/receta/result' // Fetch all required data in parallel, fail fast if any request fails async function loadDashboard(userId: string): Promise> { const result = await parallel([ () => fetchUser(userId), () => fetchUserPosts(userId), () => fetchUserStats(userId), () => fetchUserNotifications(userId), ]) return map(result, ([user, posts, stats, notifications]) => ({ user, posts, stats, notifications, })) } // Usage const dashboardResult = await loadDashboard('user_123') if (isOk(dashboardResult)) { renderDashboard(dashboardResult.value) } else { showError(dashboardResult.error) } ``` ### Pattern: Parallel Operations with Partial Success ```typescript import { promiseAllSettled, toResults } from '@ontologyio/receta/async' import { isOk } from '@ontologyio/receta/result' // Fetch all data but continue even if some requests fail async function loadDashboardResilient( userId: string ): Promise<{ user: User | null posts: Post[] stats: Stats | null notifications: Notification[] }> { const settled = await promiseAllSettled([ fetchUser(userId), fetchUserPosts(userId), fetchUserStats(userId), fetchUserNotifications(userId), ]) const results = toResults(settled) return { user: isOk(results[0]) ? results[0].value : null, posts: isOk(results[1]) ? results[1].value : [], stats: isOk(results[2]) ? results[2].value : null, notifications: isOk(results[3]) ? results[3].value : [], } } // Usage - never fails! const dashboard = await loadDashboardResilient('user_123') renderDashboard(dashboard) // Show what we have, handle nulls gracefully ``` ### Pattern: Race with First Success Receta has no race helper. `Promise.any` does the racing, and `tryCatchAsync` turns its rejection into an `Err`: ```typescript import { tryCatchAsync } from '@ontologyio/receta/result' import type { Result } from '@ontologyio/receta/result' // Try multiple endpoints, use the first one that succeeds async function fetchFromMirrors( endpoints: string[] ): Promise> { return tryCatchAsync( () => Promise.any( endpoints.map(url => fetch(url).then(r => r.json() as Promise)) ), () => 'All mirror endpoints failed' ) } // Usage const dataResult = await fetchFromMirrors([ 'https://primary.api.com/data', 'https://mirror1.api.com/data', 'https://mirror2.api.com/data', ]) ``` --- ## API Integration Patterns ### GitHub API Client with Rate Limiting GitHub API has strict rate limits (5000 requests/hour authenticated). This pattern handles rate limiting gracefully. ```typescript import * as R from 'remeda' import { isErr, isOk, ok, type Result } from '@ontologyio/receta/result' import { mapAsync, retry, type RetryError } from '@ontologyio/receta/async' interface GitHubRateLimit { limit: number remaining: number reset: number } interface GitHubRepo { name: string full_name: string stargazers_count: number language: string } class GitHubClient { private rateLimit: GitHubRateLimit | null = null private readonly baseUrl = 'https://api.github.com' constructor(private readonly token: string) {} /** * Fetch with automatic rate limit handling */ private async fetchWithRateLimit( endpoint: string ): Promise> { // Check if we're rate limited if (this.rateLimit && this.rateLimit.remaining === 0) { const now = Date.now() / 1000 const waitTime = Math.max(0, this.rateLimit.reset - now) if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime * 1000)) } } return retry( async () => { const response = await fetch(`${this.baseUrl}${endpoint}`, { headers: { 'Authorization': `Bearer ${this.token}`, 'Accept': 'application/vnd.github.v3+json', }, }) // Update rate limit from headers this.rateLimit = { limit: parseInt(response.headers.get('X-RateLimit-Limit') || '5000'), remaining: parseInt(response.headers.get('X-RateLimit-Remaining') || '5000'), reset: parseInt(response.headers.get('X-RateLimit-Reset') || '0'), } if (!response.ok) { if (response.status === 403 && this.rateLimit.remaining === 0) { throw new Error('RATE_LIMIT_EXCEEDED') } throw new Error(`GitHub API error: ${response.status}`) } return await response.json() as T }, { maxAttempts: 3, delay: 1000, maxDelay: 10000, shouldRetry: (error) => { // Retry on rate limit and 5xx errors const { message } = error as Error return message === 'RATE_LIMIT_EXCEEDED' || message.includes('5') }, } ) } /** * Fetch all repositories for an organization */ async getOrgRepos(org: string): Promise> { const repos: GitHubRepo[] = [] let page = 1 while (true) { const result = await this.fetchWithRateLimit( `/orgs/${org}/repos?page=${page}&per_page=100` ) if (isErr(result)) { return result } const pageRepos = result.value if (pageRepos.length === 0) break repos.push(...pageRepos) page++ } return ok(repos) } /** * Fetch multiple repos in parallel with concurrency control */ async getReposBatch(repoNames: string[]) { return mapAsync( repoNames, async (name) => this.fetchWithRateLimit(`/repos/${name}`), { concurrency: 10 } // Stay under rate limit ) } } // Usage const client = new GitHubClient(process.env.GITHUB_TOKEN!) // Fetch all repos for an org const orgRepos = await client.getOrgRepos('facebook') if (isOk(orgRepos)) { console.log(`Found ${orgRepos.value.length} repos`) const topRepos = R.pipe( orgRepos.value, R.sortBy(repo => -repo.stargazers_count), R.take(10) ) console.log('Top 10 repos:', topRepos) } // Fetch specific repos in parallel const repos = await client.getReposBatch([ 'facebook/react', 'facebook/react-native', 'facebook/jest', ]) if (isOk(repos)) { const successful = repos.value.filter(isOk) console.log(`Successfully fetched ${successful.length}/${repos.value.length} repos`) } ``` #### Result Version The example above already uses Result extensively. Key benefits: - No try-catch blocks needed - Type-safe error handling with `isOk` and `isErr` - Easy composition with `ok` and `err` - Rate limit errors are values, not exceptions ### Stripe Payment Processing Handle payment processing with proper error handling and retry logic for network failures. ```typescript import * as R from 'remeda' import { err, flatten, isErr, isOk, mapErr, ok, type Result } from '@ontologyio/receta/result' import { poll, retry, type RetryError } from '@ontologyio/receta/async' interface StripePaymentIntent { id: string amount: number currency: string status: 'succeeded' | 'processing' | 'requires_payment_method' | 'canceled' client_secret: string } interface StripeCharge { id: string amount: number paid: boolean status: string } interface CreatePaymentParams { amount: number currency: string customerId: string description: string metadata?: Record } class StripeClient { private readonly baseUrl = 'https://api.stripe.com/v1' constructor(private readonly secretKey: string) {} /** * Create payment intent with automatic retry */ async createPaymentIntent( params: CreatePaymentParams ): Promise> { return retry( async () => { const body = new URLSearchParams({ amount: params.amount.toString(), currency: params.currency, customer: params.customerId, description: params.description, ...R.mapValues(params.metadata || {}, String), }) const response = await fetch(`${this.baseUrl}/payment_intents`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), }) if (!response.ok) { const error = await response.json() throw new Error(error.error?.message || 'Payment failed') } return await response.json() as StripePaymentIntent }, { maxAttempts: 3, delay: 1000, shouldRetry: (error) => { // Retry on network errors, not on payment errors const { message } = error as Error return !message.includes('card') && !message.includes('insufficient') }, } ) } /** * Wait for payment to complete with timeout */ async waitForPaymentSuccess( paymentIntentId: string, maxWaitMs: number = 30000 ): Promise> { const finished = await poll( async () => { const result = await this.getPaymentIntent(paymentIntentId) if (isErr(result)) { return err(`Payment check failed after ${result.error.attempts} attempts`) } const intent = result.value if (intent.status === 'succeeded') { return ok(intent) } if (intent.status === 'canceled' || intent.status === 'requires_payment_method') { return err(`Payment ${intent.status}`) } return null // Still processing, check again }, { interval: 1000, maxAttempts: Math.ceil(maxWaitMs / 1000), timeout: maxWaitMs, } ) return flatten( mapErr(finished, () => `Payment confirmation timeout after ${maxWaitMs}ms`) ) } /** * Get payment intent details */ private async getPaymentIntent( id: string ): Promise> { return retry(async () => { const response = await fetch(`${this.baseUrl}/payment_intents/${id}`, { headers: { 'Authorization': `Bearer ${this.secretKey}`, }, }) if (!response.ok) { throw new Error(`Failed to fetch payment intent: ${response.status}`) } return await response.json() as StripePaymentIntent }) } /** * Process refund with validation */ async refundPayment( paymentIntentId: string, amount?: number ): Promise> { return retry(async () => { const body = new URLSearchParams({ payment_intent: paymentIntentId, ...(amount && { amount: amount.toString() }), }) const response = await fetch(`${this.baseUrl}/refunds`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), }) if (!response.ok) { const error = await response.json() throw new Error(error.error?.message || 'Refund failed') } return await response.json() as StripeCharge }) } } // Usage const stripe = new StripeClient(process.env.STRIPE_SECRET_KEY!) // Create and wait for payment const paymentResult = await stripe.createPaymentIntent({ amount: 2000, // $20.00 currency: 'usd', customerId: 'cus_123', description: 'Premium subscription', metadata: { orderId: 'order_456', userId: 'user_789', }, }) if (isOk(paymentResult)) { const intent = paymentResult.value console.log(`Payment intent created: ${intent.id}`) // Wait for payment to complete const completedResult = await stripe.waitForPaymentSuccess(intent.id) if (isOk(completedResult)) { console.log('Payment succeeded!') } else { console.error('Payment failed:', completedResult.error) // Optionally refund await stripe.refundPayment(intent.id) } } ``` #### Result Version The Stripe example already uses Result for type-safe payment processing: - Payment failures return `err` instead of throwing - `waitForPaymentSuccess` uses `poll` for safe polling with a time limit - Refunds use `retry` to handle transient failures - All operations compose cleanly with pipes and `isOk` ### AWS S3 File Operations Upload and download files from S3 with proper error handling. ```typescript import * as R from 'remeda' import { isErr, isOk, ok, type Result } from '@ontologyio/receta/result' import { mapAsync, retry, type RetryError } from '@ontologyio/receta/async' import { createHash } from 'crypto' interface S3Object { key: string size: number lastModified: Date etag: string } interface UploadOptions { contentType?: string metadata?: Record } class S3Client { private readonly baseUrl: string constructor( private readonly accessKeyId: string, private readonly secretAccessKey: string, private readonly bucket: string, private readonly region: string = 'us-east-1' ) { this.baseUrl = `https://${bucket}.s3.${region}.amazonaws.com` } /** * Upload file with automatic retry and chunking for large files */ async uploadFile( key: string, content: Buffer | Blob, options: UploadOptions = {} ): Promise> { const contentBuffer = content instanceof Blob ? Buffer.from(await content.arrayBuffer()) : content const contentLength = contentBuffer.length const contentMD5 = createHash('md5').update(contentBuffer).digest('base64') return retry( async () => { const response = await fetch(`${this.baseUrl}/${key}`, { method: 'PUT', headers: { 'Content-Type': options.contentType || 'application/octet-stream', 'Content-Length': contentLength.toString(), 'Content-MD5': contentMD5, ...this.getAuthHeaders('PUT', key), ...R.mapKeys(options.metadata || {}, k => `x-amz-meta-${k}`), }, body: contentBuffer, }) if (!response.ok) { throw new Error(`S3 upload failed: ${response.status}`) } const etag = response.headers.get('ETag')?.replace(/"/g, '') || '' return { key, size: contentLength, lastModified: new Date(), etag, } }, { maxAttempts: 3, delay: 1000, shouldRetry: (error) => { // Retry on network errors, not on auth errors const { message } = error as Error return !message.includes('403') && !message.includes('401') }, } ) } /** * Download file with retry */ async downloadFile(key: string): Promise> { return retry(async () => { const response = await fetch(`${this.baseUrl}/${key}`, { headers: this.getAuthHeaders('GET', key), }) if (!response.ok) { throw new Error(`S3 download failed: ${response.status}`) } const arrayBuffer = await response.arrayBuffer() return Buffer.from(arrayBuffer) }) } /** * Upload multiple files in parallel */ async uploadBatch( files: Array<{ key: string; content: Buffer; options?: UploadOptions }>, concurrency: number = 5 ) { return mapAsync( files, async (file) => this.uploadFile(file.key, file.content, file.options), { concurrency } ) } /** * List objects with pagination */ async listObjects(prefix?: string): Promise> { const objects: S3Object[] = [] let continuationToken: string | undefined while (true) { const url = new URL(this.baseUrl) url.searchParams.set('list-type', '2') if (prefix) url.searchParams.set('prefix', prefix) if (continuationToken) { url.searchParams.set('continuation-token', continuationToken) } const result = await retry(async () => { const response = await fetch(url.toString(), { headers: this.getAuthHeaders('GET', '/'), }) if (!response.ok) { throw new Error(`S3 list failed: ${response.status}`) } return await response.text() }) if (isErr(result)) { return result } // Parse XML response (simplified - use xml2js in production) const xmlText = result.value const keyMatches = xmlText.matchAll(/(.*?)<\/Key>/g) const sizeMatches = xmlText.matchAll(/(.*?)<\/Size>/g) const pageObjects = Array.from(keyMatches).map((match, i) => ({ key: match[1], size: parseInt(Array.from(sizeMatches)[i]?.[1] || '0'), lastModified: new Date(), etag: '', })) objects.push(...pageObjects) // Check for more pages const isTruncated = xmlText.includes('true') if (!isTruncated) break const tokenMatch = xmlText.match(/(.*?)<\/NextContinuationToken>/) continuationToken = tokenMatch?.[1] if (!continuationToken) break } return ok(objects) } /** * Generate signed auth headers (simplified AWS Signature V4) */ private getAuthHeaders(method: string, path: string): Record { // In production, use aws-sdk or proper signature generation // This is a placeholder return { 'Authorization': `AWS ${this.accessKeyId}:signature`, 'x-amz-date': new Date().toISOString(), } } } // Usage const s3 = new S3Client( process.env.AWS_ACCESS_KEY_ID!, process.env.AWS_SECRET_ACCESS_KEY!, 'my-bucket', 'us-east-1' ) // Upload single file const uploadResult = await s3.uploadFile( 'uploads/image.png', Buffer.from('...'), { contentType: 'image/png', metadata: { userId: '123', originalName: 'photo.png', }, } ) if (isOk(uploadResult)) { console.log('Upload successful:', uploadResult.value) } // Upload multiple files const files = [ { key: 'file1.txt', content: Buffer.from('content1') }, { key: 'file2.txt', content: Buffer.from('content2') }, { key: 'file3.txt', content: Buffer.from('content3') }, ] const results = await s3.uploadBatch(files, 3) if (isOk(results)) { const successful = results.value.filter(isOk) console.log(`Uploaded ${successful.length}/${files.length} files`) } // List and download const listResult = await s3.listObjects('uploads/') if (isOk(listResult)) { const images = listResult.value.filter(obj => obj.key.endsWith('.png')) const downloads = await mapAsync( images, async (obj) => s3.downloadFile(obj.key), { concurrency: 5 } ) if (isOk(downloads)) { console.log(`Downloaded ${downloads.value.filter(isOk).length} images`) } } ``` #### Result Version The S3 example demonstrates Result-based file operations: - Upload/download operations return `Result` instead of throwing - `uploadBatch` uses `mapAsync` to process files with concurrency control - All network errors become Result errors for consistent handling - Batch operations use `filter(isOk)` to count successes ## Bulk Operations ### User Import/Export Handle large-scale user data imports with validation and error recovery. ```typescript import * as R from 'remeda' import { err, isErr, isOk, ok, type Result } from '@ontologyio/receta/result' import { mapAsync } from '@ontologyio/receta/async' interface UserRecord { email: string name: string role: 'admin' | 'user' | 'guest' metadata?: Record } interface ImportResult { total: number successful: number failed: number errors: Array<{ row: number; email: string; error: string }> } /** * Import users from CSV with validation and batch processing */ async function importUsers( csvContent: string, options: { batchSize?: number concurrency?: number onProgress?: (processed: number, total: number) => void } = {} ): Promise> { const { batchSize = 100, concurrency = 5, onProgress } = options // Parse CSV const lines = csvContent.trim().split('\n') const headers = lines[0].split(',') const rows = lines.slice(1) // Validate headers const requiredHeaders = ['email', 'name', 'role'] const missingHeaders = requiredHeaders.filter(h => !headers.includes(h)) if (missingHeaders.length > 0) { return err(`Missing required headers: ${missingHeaders.join(', ')}`) } // Parse rows const users = rows.map((line, idx) => { const values = line.split(',') const user: Record = {} headers.forEach((header, i) => { user[header] = values[i]?.trim() || '' }) return { row: idx + 2, user } // +2 for header and 1-indexed }) const result: ImportResult = { total: users.length, successful: 0, failed: 0, errors: [], } // Process in batches const batches = R.chunk(users, batchSize) for (const batch of batches) { const results = await mapAsync( batch, async ({ row, user }) => { // Validate user const validation = validateUser(user) if (isErr(validation)) { return { row, email: user.email, error: validation.error } } // Create user in database const createResult = await createUser(validation.value) if (isErr(createResult)) { return { row, email: user.email, error: createResult.error } } return { row, email: user.email, success: true } }, { concurrency } ) // The mapper reports failures as values, so this only catches a thrown error if (isErr(results)) { return err('Import stopped by an unexpected error') } // Collect results for (const res of results.value) { if ('success' in res) { result.successful++ } else { result.failed++ result.errors.push(res) } } // Report progress if (onProgress) { const processed = result.successful + result.failed onProgress(processed, result.total) } } return ok(result) } /** * Validate user record */ function validateUser( user: Record ): Result { // Email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailRegex.test(user.email)) { return err(`Invalid email: ${user.email}`) } // Name validation if (!user.name || user.name.length < 2) { return err('Name must be at least 2 characters') } // Role validation const validRoles = ['admin', 'user', 'guest'] if (!validRoles.includes(user.role)) { return err(`Invalid role: ${user.role}`) } return ok({ email: user.email, name: user.name, role: user.role as UserRecord['role'], metadata: R.omit(user, ['email', 'name', 'role']), }) } /** * Create user in database (mock) */ async function createUser(user: UserRecord): Promise> { // Simulate API call await new Promise(resolve => setTimeout(resolve, 100)) // Simulate occasional failures if (Math.random() < 0.05) { return err('Database error') } return ok(undefined) } // Usage const csv = `email,name,role,department john@example.com,John Doe,admin,Engineering jane@example.com,Jane Smith,user,Sales invalid-email,Bad User,admin,Engineering bob@example.com,Bob Johnson,user,Marketing` const importResult = await importUsers(csv, { batchSize: 50, concurrency: 10, onProgress: (processed, total) => { console.log(`Progress: ${processed}/${total} (${(processed/total*100).toFixed(1)}%)`) }, }) if (isOk(importResult)) { const stats = importResult.value console.log(`Import complete: Total: ${stats.total} Successful: ${stats.successful} Failed: ${stats.failed} `) if (stats.errors.length > 0) { console.log('\nErrors:') stats.errors.forEach(err => { console.log(` Row ${err.row} (${err.email}): ${err.error}`) }) } } ``` #### Result Version The User Import example shows comprehensive Result usage: - CSV validation returns `err` for malformed data - Each row import returns `Result` for tracking failures - Validation errors are collected without stopping the entire import - Progress reporting works seamlessly with Result-based error tracking - Final statistics aggregate successes and failures from Result values ### Data Synchronization Sync data between systems with conflict resolution and incremental updates. ```typescript import * as R from 'remeda' import { err, isErr, isOk, ok, type Result } from '@ontologyio/receta/result' import { mapAsync } from '@ontologyio/receta/async' interface SyncRecord { id: string data: unknown version: number updatedAt: Date } interface SyncResult { created: number updated: number deleted: number conflicts: number errors: Array<{ id: string; error: string }> } /** * Sync records between source and destination */ async function syncData( sourceRecords: SyncRecord[], destinationRecords: SyncRecord[], options: { conflictResolution?: 'source' | 'destination' | 'newest' concurrency?: number } = {} ): Promise> { const { conflictResolution = 'newest', concurrency = 10 } = options const result: SyncResult = { created: 0, updated: 0, deleted: 0, conflicts: 0, errors: [], } // Index destination records const destById = R.indexBy(destinationRecords, r => r.id) const sourceById = R.indexBy(sourceRecords, r => r.id) // Find records to create, update, and delete const toCreate: SyncRecord[] = [] const toUpdate: Array<{ source: SyncRecord; dest: SyncRecord }> = [] const toDelete: SyncRecord[] = [] // Check source records for (const sourceRecord of sourceRecords) { const destRecord = destById[sourceRecord.id] if (!destRecord) { toCreate.push(sourceRecord) } else if (sourceRecord.version !== destRecord.version) { toUpdate.push({ source: sourceRecord, dest: destRecord }) } } // Check for deletions for (const destRecord of destinationRecords) { if (!sourceById[destRecord.id]) { toDelete.push(destRecord) } } // Process creates const createResults = await mapAsync( toCreate, async (record) => { const res = await createRecord(record) return { id: record.id, result: res } }, { concurrency } ) if (isErr(createResults)) return err('Sync stopped while creating records') for (const { id, result: res } of createResults.value) { if (isOk(res)) { result.created++ } else { result.errors.push({ id, error: res.error }) } } // Process updates with conflict resolution const updateResults = await mapAsync( toUpdate, async ({ source, dest }) => { // Detect conflicts (both modified since last sync) const isConflict = source.version > dest.version + 1 if (isConflict) { result.conflicts++ } // Resolve conflict let recordToUse: SyncRecord if (conflictResolution === 'source') { recordToUse = source } else if (conflictResolution === 'destination') { recordToUse = dest } else { // Use newest recordToUse = source.updatedAt > dest.updatedAt ? source : dest } const res = await updateRecord(recordToUse) return { id: source.id, result: res } }, { concurrency } ) if (isErr(updateResults)) return err('Sync stopped while updating records') for (const { id, result: res } of updateResults.value) { if (isOk(res)) { result.updated++ } else { result.errors.push({ id, error: res.error }) } } // Process deletes const deleteResults = await mapAsync( toDelete, async (record) => { const res = await deleteRecord(record.id) return { id: record.id, result: res } }, { concurrency } ) if (isErr(deleteResults)) return err('Sync stopped while deleting records') for (const { id, result: res } of deleteResults.value) { if (isOk(res)) { result.deleted++ } else { result.errors.push({ id, error: res.error }) } } return ok(result) } // Mock operations async function createRecord(record: SyncRecord): Promise> { await new Promise(resolve => setTimeout(resolve, 50)) return ok(undefined) } async function updateRecord(record: SyncRecord): Promise> { await new Promise(resolve => setTimeout(resolve, 50)) return ok(undefined) } async function deleteRecord(id: string): Promise> { await new Promise(resolve => setTimeout(resolve, 50)) return ok(undefined) } // Usage const sourceRecords: SyncRecord[] = [ { id: '1', data: { name: 'A' }, version: 2, updatedAt: new Date('2024-01-15') }, { id: '2', data: { name: 'B' }, version: 1, updatedAt: new Date('2024-01-14') }, { id: '3', data: { name: 'C' }, version: 1, updatedAt: new Date('2024-01-16') }, ] const destRecords: SyncRecord[] = [ { id: '1', data: { name: 'A-old' }, version: 1, updatedAt: new Date('2024-01-10') }, { id: '2', data: { name: 'B' }, version: 1, updatedAt: new Date('2024-01-14') }, { id: '4', data: { name: 'D' }, version: 1, updatedAt: new Date('2024-01-12') }, ] const syncResult = await syncData(sourceRecords, destRecords, { conflictResolution: 'newest', concurrency: 5, }) if (isOk(syncResult)) { console.log('Sync complete:', syncResult.value) } ``` #### Result Version The Data Sync example uses Result for safe synchronization: - Create/update/delete operations return `Result` - Conflict detection and resolution happen without exceptions - Errors are accumulated in a structured format - The final sync result aggregates all operation results - Type-safe error handling ensures no sync failures are missed ### Batch Email Sending Send emails in batches with rate limiting and failure tracking. ```typescript import * as R from 'remeda' import { err, isErr, isOk, ok, type Result } from '@ontologyio/receta/result' import { mapAsync } from '@ontologyio/receta/async' interface Email { to: string subject: string body: string attachments?: Array<{ filename: string; content: Buffer }> } interface SendResult { email: string messageId?: string error?: string } /** * Send emails in batches with rate limiting */ async function sendEmailBatch( emails: Email[], options: { batchSize?: number delayBetweenBatches?: number maxRetriesPerEmail?: number } = {} ): Promise> { const { batchSize = 50, delayBetweenBatches = 1000, maxRetriesPerEmail = 3, } = options const results: SendResult[] = [] const batches = R.chunk(emails, batchSize) for (const [batchIndex, batch] of batches.entries()) { console.log(`Processing batch ${batchIndex + 1}/${batches.length}`) // Send batch in parallel const batchResults = await mapAsync( batch, async (email) => { let lastError: string | undefined // Retry logic for (let attempt = 1; attempt <= maxRetriesPerEmail; attempt++) { const result = await sendEmail(email) if (isOk(result)) { return { email: email.to, messageId: result.value, } } lastError = result.error if (attempt < maxRetriesPerEmail) { // Wait before retry await new Promise(resolve => setTimeout(resolve, 1000 * attempt) ) } } return { email: email.to, error: lastError, } }, { concurrency: 10 } // Limit concurrent sends ) if (isErr(batchResults)) { return err(`Batch ${batchIndex + 1} stopped by an unexpected error`) } results.push(...batchResults.value) // Delay between batches to respect rate limits if (batchIndex < batches.length - 1) { await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)) } } return ok(results) } /** * Send single email (mock implementation) */ async function sendEmail(email: Email): Promise> { // Simulate API call await new Promise(resolve => setTimeout(resolve, 100)) // Simulate occasional failures if (Math.random() < 0.1) { return err('SMTP error: Connection timeout') } return ok(`msg-${Date.now()}-${Math.random()}`) } // Usage const emails: Email[] = Array.from({ length: 200 }, (_, i) => ({ to: `user${i}@example.com`, subject: 'Welcome!', body: `Hello user ${i}!`, })) const result = await sendEmailBatch(emails, { batchSize: 50, delayBetweenBatches: 1000, maxRetriesPerEmail: 3, }) if (isOk(result)) { const successful = result.value.filter(r => r.messageId) const failed = result.value.filter(r => r.error) console.log(`Email sending complete: Total: ${result.value.length} Successful: ${successful.length} Failed: ${failed.length} `) if (failed.length > 0) { console.log('\nFailed emails:') failed.forEach(r => { console.log(` ${r.email}: ${r.error}`) }) } } ``` #### Result Version The Batch Email example demonstrates Result for email campaigns: - Each email send returns `Result` with message ID or error - Retry logic is built into the Result flow without try-catch - Success/failure tracking is type-safe and explicit - Final report aggregates Result values for statistics - No silent failures - every error is captured and reported ## Real-time Updates ### Webhook Delivery Deliver webhooks with retry and exponential backoff. ```typescript import { isOk, ok, type Result } from '@ontologyio/receta/result' import { retry, type RetryError } from '@ontologyio/receta/async' interface WebhookEvent { id: string type: string data: unknown timestamp: Date } interface WebhookEndpoint { url: string secret: string events: string[] } /** * Deliver webhook with retry and signature */ async function deliverWebhook( endpoint: WebhookEndpoint, event: WebhookEvent ): Promise> { // Check if endpoint is subscribed to this event if (!endpoint.events.includes(event.type)) { return ok(undefined) // Skip delivery } const payload = JSON.stringify(event) const signature = await generateSignature(payload, endpoint.secret) return retry( async () => { const response = await fetch(endpoint.url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'X-Webhook-ID': event.id, 'X-Webhook-Timestamp': event.timestamp.toISOString(), }, body: payload, }) if (!response.ok) { throw new Error(`Webhook delivery failed: ${response.status}`) } return undefined }, { maxAttempts: 5, delay: 1000, maxDelay: 60000, backoff: 2, shouldRetry: (error) => { // Retry on 5xx errors and timeouts, not on 4xx return !(error as Error).message.includes('4') }, } ) } /** * Generate HMAC signature for webhook */ async function generateSignature( payload: string, secret: string ): Promise { const encoder = new TextEncoder() const keyData = encoder.encode(secret) const messageData = encoder.encode(payload) const key = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', key, messageData) const hashArray = Array.from(new Uint8Array(signature)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } // Usage const endpoint: WebhookEndpoint = { url: 'https://api.example.com/webhooks', secret: 'whsec_...', events: ['user.created', 'user.updated'], } const event: WebhookEvent = { id: 'evt_123', type: 'user.created', data: { userId: '123', email: 'user@example.com', }, timestamp: new Date(), } const result = await deliverWebhook(endpoint, event) if (isOk(result)) { console.log('Webhook delivered successfully') } else { console.error('Webhook delivery failed:', result.error) } ``` #### Result Version The Webhook Delivery example shows Result for reliable delivery: - `deliverWebhook` returns `Result` for explicit success/failure - `retry` wraps network calls with Result-based retry logic - Signature generation and validation errors are Result values - Webhook failures are logged without throwing exceptions - Easy to track delivery success rates with Result filtering ### Job Status Polling Poll for job completion with timeout and progress updates. ```typescript import { err, flatten, isErr, isOk, mapErr, ok, type Result } from '@ontologyio/receta/result' import { poll } from '@ontologyio/receta/async' interface Job { id: string status: 'pending' | 'running' | 'completed' | 'failed' progress?: number result?: unknown error?: string } /** * Poll job status until completion */ async function waitForJobCompletion( jobId: string, options: { pollInterval?: number maxWaitTime?: number onProgress?: (job: Job) => void } = {} ): Promise> { const { pollInterval = 1000, maxWaitTime = 300000, // 5 minutes onProgress, } = options const finished = await poll( async () => { const jobResult = await fetchJobStatus(jobId) if (isErr(jobResult)) { return jobResult } const job = jobResult.value // Report progress if (onProgress) { onProgress(job) } // Check terminal states if (job.status === 'completed') { return ok(job) } if (job.status === 'failed') { return err(job.error || 'Job failed') } return null // Not done yet, poll again }, { interval: pollInterval, maxAttempts: Math.ceil(maxWaitTime / pollInterval), timeout: maxWaitTime, } ) return flatten( mapErr(finished, () => `Job did not complete within ${maxWaitTime}ms`) ) } /** * Fetch job status (mock) */ async function fetchJobStatus(jobId: string): Promise> { await new Promise(resolve => setTimeout(resolve, 100)) // Simulate job progress const progress = Math.min(100, (Date.now() % 10000) / 100) const status = progress < 100 ? 'running' : 'completed' return ok({ id: jobId, status, progress, result: status === 'completed' ? { data: 'result' } : undefined, }) } // Usage const jobId = 'job_123' const result = await waitForJobCompletion(jobId, { pollInterval: 2000, maxWaitTime: 60000, onProgress: (job) => { console.log(`Job progress: ${job.progress?.toFixed(1)}%`) }, }) if (isOk(result)) { console.log('Job completed:', result.value.result) } else { console.error('Job failed:', result.error) } ``` #### Result Version The Job Polling example uses Result for async job tracking: - `waitForJobCompletion` returns `Result` when complete or failed - `poll` runs the loop and enforces the time limit - Job status checks use `isErr` for early termination - Progress callbacks work seamlessly with Result-based flow - Terminal states (completed/failed) are distinguished by Result value ## Error Recovery ### Circuit Breaker Pattern Prevent cascading failures with circuit breaker. ```typescript import { err, isErr, ok, type Result } from '@ontologyio/receta/result' type CircuitState = 'closed' | 'open' | 'half-open' interface CircuitBreakerOptions { failureThreshold: number successThreshold: number timeout: number } class CircuitBreaker { private state: CircuitState = 'closed' private failureCount = 0 private successCount = 0 private nextAttempt = Date.now() constructor(private options: CircuitBreakerOptions) {} async execute( fn: () => Promise ): Promise> { if (this.state === 'open') { if (Date.now() < this.nextAttempt) { return err('Circuit breaker is open') } this.state = 'half-open' } try { const result = await fn() this.onSuccess() return ok(result) } catch (error) { this.onFailure() return err(error instanceof Error ? error.message : 'Unknown error') } } private onSuccess(): void { this.failureCount = 0 if (this.state === 'half-open') { this.successCount++ if (this.successCount >= this.options.successThreshold) { this.state = 'closed' this.successCount = 0 } } } private onFailure(): void { this.failureCount++ this.successCount = 0 if (this.failureCount >= this.options.failureThreshold) { this.state = 'open' this.nextAttempt = Date.now() + this.options.timeout } } getState(): CircuitState { return this.state } } // Usage const breaker = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, timeout: 60000, }) async function callExternalAPI(): Promise { const result = await breaker.execute(async () => { const response = await fetch('https://api.example.com/data') if (!response.ok) throw new Error('API error') return await response.text() }) if (isErr(result)) { console.log('Circuit breaker state:', breaker.getState()) return 'fallback data' } return result.value } ``` #### Result Version The Circuit Breaker example demonstrates Result for fault tolerance: - `execute` method returns `Result` for all operations - Circuit state transitions are based on Result success/failure - Open circuit returns `err` immediately without calling function - Half-open state tests recovery using Result-based success tracking - Fallback logic is explicit and type-safe with Result pattern ## Performance Optimization ### Concurrency Tuning Find optimal concurrency for your workload. ```typescript import { mapAsync } from '@ontologyio/receta/async' /** * Benchmark different concurrency levels */ async function findOptimalConcurrency( items: T[], fn: (item: T) => Promise, concurrencyLevels: number[] = [1, 5, 10, 20, 50, 100] ): Promise<{ concurrency: number; duration: number }> { const results: Array<{ concurrency: number; duration: number }> = [] for (const concurrency of concurrencyLevels) { const start = Date.now() await mapAsync(items, fn, { concurrency }) const duration = Date.now() - start results.push({ concurrency, duration }) console.log(`Concurrency ${concurrency}: ${duration}ms`) } // Find fastest return results.reduce((best, current) => current.duration < best.duration ? current : best ) } // Usage const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/data/${i}` ) const optimal = await findOptimalConcurrency( urls, async (url) => { const response = await fetch(url) return await response.json() } ) console.log(`Optimal concurrency: ${optimal.concurrency}`) ``` --- ## Summary: Why Use Result Patterns? The patterns in this document demonstrate the power of Result-based async operations: ### Benefits 1. **No try-catch blocks**: Errors are values that compose naturally 2. **Type-safe error handling**: TypeScript ensures you handle all error cases 3. **Explicit error propagation**: See exactly where errors can occur 4. **Easy composition**: Chain operations with `pipe`, `map`, `flatMap` 5. **Consistent API**: `retry`, `mapAsync`, `poll` and `batch` all return a Result 6. **Better testing**: Mock errors by returning `err` values 7. **No silent failures**: All errors are explicit and tracked ### When to Use Result vs Exceptions **Use Result when:** - Building API clients with expected failures (rate limits, 404s, etc.) - Processing batches where some items may fail - Implementing retry logic with multiple failure modes - Need to collect errors without stopping execution - Want explicit error handling in function signatures **Use exceptions when:** - Truly unexpected errors (bugs, programmer errors) - Need to abort execution immediately - Working with libraries that expect exception handling - Validating function preconditions ### Migration Path To migrate existing try-catch code to Result: ```typescript // Before: try-catch async function fetchUser(id: string): Promise { try { const response = await fetch(`/users/${id}`) if (!response.ok) throw new Error('Not found') return await response.json() } catch (error) { console.error('Failed to fetch user:', error) throw error } } // After: Result async function fetchUser(id: string): Promise> { return retry(async () => { const response = await fetch(`/users/${id}`) if (!response.ok) throw new Error('Not found') return await response.json() }, { maxAttempts: 3 }) } ``` This document provides production-ready patterns for common async operations. All examples use real APIs and include proper error handling, retry logic, and performance optimization. --- # Retry and Timeout Patterns > Building resilient async operations that handle transient failures gracefully. ## Table of Contents 1. [Retry Patterns](#retry-patterns) 2. [Timeout Patterns](#timeout-patterns) 3. [Result Integration](#result-integration) 4. [Combined Patterns](#combined-patterns) 5. [Real-World Examples](#real-world-examples) 6. [Best Practices](#best-practices) 7. [Common Mistakes](#common-mistakes) --- ## Retry Patterns ### Basic Retry with Exponential Backoff The retry function automatically handles transient failures with exponential backoff. It runs your function again whenever it throws or rejects, and returns a `Result`: ```typescript import { retry } from '@ontologyio/receta/async' // Basic retry with defaults (3 attempts, 1s initial delay, 2x backoff) const fetchUser = async (id: string) => { return retry(async () => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) } // Custom retry configuration const fetchWithCustomRetry = async (url: string) => { return retry( async () => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 5, // Try up to 5 times delay: 500, // Start with 500ms delay backoff: 2, // Double delay each time maxDelay: 10000, // Cap at 10 seconds } ) } ``` **Delay progression with exponential backoff:** - Attempt 1: Immediate - Attempt 2: 500ms delay - Attempt 3: 1000ms delay (500 Γ— 2) - Attempt 4: 2000ms delay (1000 Γ— 2) - Attempt 5: 4000ms delay (2000 Γ— 2) **Adding jitter:** when many clients fail at once (e.g. a rate limit), identical backoff makes them all retry at the same instant. Set `jitter` to add a uniform random 0..`jitter` ms to each delay, before the `maxDelay` cap: ```typescript await retry(() => callApi(), { delay: 500, backoff: 2, maxDelay: 10000, jitter: 250, // wait = min(500 Γ— 2^n + random(0, 250), 10000) }) ``` `onRetry` receives the jittered delay that will actually be waited. The default is `jitter: 0` (no randomness). ### Conditional Retry with shouldRetry Only retry specific types of errors: ```typescript // Retry only on network errors, not client errors const fetchData = async (url: string) => { return retry( async () => { const res = await fetch(url) if (!res.ok) { const error: any = new Error(`HTTP ${res.status}`) error.status = res.status throw error } return res.json() }, { maxAttempts: 3, shouldRetry: (error, attempt) => { // Don't retry client errors (4xx) if (error instanceof Error && 'status' in error) { const status = (error as any).status if (status >= 400 && status < 500) { return false } } // Retry server errors (5xx) and network errors return true } } ) } // Retry based on specific error types class NetworkError extends Error { constructor(message: string) { super(message) this.name = 'NetworkError' } } class RateLimitError extends Error { constructor(public resetAt: number) { super('Rate limit exceeded') this.name = 'RateLimitError' } } const smartRetry = async (operation: () => Promise) => { return retry(operation, { maxAttempts: 5, shouldRetry: (error, attempt) => { // Always retry network errors if (error instanceof NetworkError) { return true } // Retry rate limits only on first few attempts if (error instanceof RateLimitError) { return attempt <= 2 } // Don't retry other errors return false } }) } ``` ### Retry with Monitoring Use the onRetry callback to log, monitor, or notify: ```typescript import { retry } from '@ontologyio/receta/async' const fetchWithMonitoring = async (url: string) => { return retry( async () => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3, delay: 1000, onRetry: (error, attempt, delay) => { // Log retry attempts console.warn(`Retry attempt ${attempt} after ${delay}ms`, { url, error: error instanceof Error ? error.message : String(error), nextDelay: delay, }) // Send metrics to monitoring service metrics.increment('api.retry', { url, attempt: String(attempt), }) // Alert on multiple retries if (attempt >= 2) { alerts.send({ severity: 'warning', message: `Multiple retry attempts for ${url}`, metadata: { attempt, error }, }) } } } ) } ``` ### Constant Delay (No Backoff) For operations that need consistent retry intervals: ```typescript // Poll an endpoint every second const pollStatus = async (jobId: string) => { return retry( async () => { const res = await fetch(`/api/jobs/${jobId}/status`) const data = await res.json() if (data.status === 'pending') { throw new Error('Job still pending') } return data }, { maxAttempts: 10, delay: 1000, backoff: 1, // No backoff, constant 1 second delay } ) } ``` --- ## Timeout Patterns ### Basic Timeout Add a timeout to any promise. You get a `Result` back: ```typescript import { timeout } from '@ontologyio/receta/async' import { isErr, mapErr } from '@ontologyio/receta/result' // Basic timeout const fetchWithTimeout = async (url: string) => { const result = await timeout( fetch(url).then(r => r.json()), 5000 // 5 second timeout ) if (isErr(result)) { console.error('Request timed out after 5 seconds') } return result } // Custom timeout error const fetchWithCustomError = async (url: string) => { const result = await timeout(fetch(url).then(r => r.json()), 3000) return mapErr(result, () => new Error(`Request to ${url} timed out`)) } ``` Two things to keep in mind: - Only the timeout becomes an `Err`. If the promise rejects first, `timeout` rejects with that error, so [handle both](#handling-timeouterror) when that can happen. - `timeout` stops waiting, but the operation keeps running. To stop it too, pass it an `AbortSignal` and abort when you get an `Err`. ### Function-Level Timeout Wrap functions to automatically apply timeouts. Receta has no wrapper for this, but `timeout` makes it short: ```typescript import { timeout } from '@ontologyio/receta/async' const withTimeout = ( fn: (...args: Args) => Promise, ms: number ) => (...args: Args) => timeout(fn(...args), ms) // Create a fetch function with built-in timeout const fetchJSON = withTimeout( async (url: string) => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, 5000 // All calls time out after 5 seconds ) // Use it like a normal function that returns a Result const user = await fetchJSON('/api/users/123') const posts = await fetchJSON('/api/posts') // Each call has its own 5-second timeout ``` ### Per-Operation Timeout Different operations need different timeouts: ```typescript // Short timeout for critical data const fetchCriticalData = withTimeout( async (id: string) => { const res = await fetch(`/api/critical/${id}`) return res.json() }, 2000 // 2 seconds max ) // Longer timeout for uploads const uploadFile = withTimeout( async (file: File) => { const formData = new FormData() formData.append('file', file) const res = await fetch('/api/upload', { method: 'POST', body: formData, }) return res.json() }, 30000 // 30 seconds for large files ) // Very long timeout for exports const exportData = withTimeout( async (query: string) => { const res = await fetch('/api/export', { method: 'POST', body: JSON.stringify({ query }), }) return res.json() }, 120000 // 2 minutes for complex exports ) ``` ### Handling TimeoutError Distinguish timeout errors from other failures. `tryCatchAsync` catches what the operation rejects with, and `flatten` puts both kinds of failure in one `Result`: ```typescript import { timeout, TimeoutError } from '@ontologyio/receta/async' import { flatten, isOk, tryCatchAsync } from '@ontologyio/receta/result' const robustFetch = async (url: string) => { const result = flatten( await tryCatchAsync(() => timeout(fetch(url).then(r => r.json()), 5000)) ) if (isOk(result)) return result.value if (result.error instanceof TimeoutError) { // Timeout-specific handling console.error('Request timed out, using cached data') return getCachedData(url) } if (result.error instanceof TypeError && result.error.message.includes('fetch')) { // Network error console.error('Network error, retrying...') return robustFetch(url) // Retry } // Other errors throw result.error } ``` --- ## Result Integration ### Why Result Pattern? Hand-written retry and timeout helpers usually throw, making error handling verbose and error-prone. Receta's return errors as values: ```typescript // Traditional: Requires try-catch try { const data = await fetchWithManualRetry('/api/data') } catch (error) { // Error handling here } // Result: Errors as values const result = await retry(() => fetch('/api/data')) if (isOk(result)) { // Use result.value } else { // Handle result.error } ``` **Benefits:** - βœ… Errors visible in function signature - βœ… Composable with Result utilities (map, flatMap, unwrapOr) - βœ… No exceptions - pure functional error handling - βœ… Full error context (attempts, last error, etc.) --- ### retry() Type-safe retry that returns a Result instead of throwing exceptions. #### Signature ```typescript title="Signature" function retry( fn: () => Promise, options?: RetryOptions ): Promise> type RetryError = { type: 'max_attempts_exceeded' lastError: unknown attempts: number } ``` When `shouldRetry` returns false you get the same error, with `attempts` counting the tries so far. #### Real-World: Database Connection with Result ```typescript import { retry } from '@ontologyio/receta/async' import { unwrapOr, mapErr, isOk } from '@ontologyio/receta/result' import * as R from 'remeda' const connectToDatabase = async (): Promise => { const result = await retry( async () => { const db = await mongoose.connect(connectionString) return db }, { maxAttempts: 5, delay: 1000, backoff: 2, onRetry: (error, attempt, delay) => { logger.warn(`DB connection attempt ${attempt} failed, retrying in ${delay}ms`) } } ) return R.pipe( result, mapErr(error => { logger.error(`DB connection failed after ${error.attempts} attempts`) return error }), unwrapOr(fallbackDatabase) ) } // No try-catch needed! const db = await connectToDatabase() ``` #### Real-World: API Call with Error Recovery ```typescript import { retry } from '@ontologyio/receta/async' import { match, ok } from '@ontologyio/receta/result' const fetchUser = async (id: string) => { const result = await retry( async () => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3, delay: 1000, backoff: 2, } ) return match(result, { onOk: (user) => user, onErr: (error) => { // Graceful fallback logger.error('Failed to fetch user, using cache', { id, attempts: error.attempts, lastError: error.lastError }) return getCachedUser(id) } }) } ``` #### Comparison: Traditional vs Result | Aspect | Hand-written retry that throws | `retry()` | |--------|-------------------------------|-----------| | Error handling | try-catch required | Explicit Result type | | Error visibility | Hidden (throws) | Visible in signature | | Composability | Limited | Full Result API (map, flatMap, etc.) | | Error context | Lost in exception | Preserved (attempts, lastError) | | Type safety | ❌ Error type unknown | βœ… `Result` | | Pure function | ❌ Throws exceptions | βœ… Returns values | #### Advanced: Combining Multiple Retries ```typescript import { retry } from '@ontologyio/receta/async' import { isErr, isOk, map } from '@ontologyio/receta/result' // Fetch user, then their posts (both with retry) const fetchUserWithPosts = async (userId: string) => { const userResult = await retry( () => fetchUser(userId), { maxAttempts: 3, delay: 1000 } ) if (isErr(userResult)) return userResult const postsResult = await retry( () => fetchPosts(userId), { maxAttempts: 3, delay: 500 } ) return map(postsResult, (posts) => ({ user: userResult.value, posts })) } // Usage const result = await fetchUserWithPosts('123') if (isOk(result)) { console.log('User with posts:', result.value) } else { console.error('Error:', result.error) } ``` --- ### timeout() Add a timeout with a Result for type-safe timeout handling. #### Signature ```typescript title="Signature" function timeout( promise: Promise, ms: number ): Promise> class TimeoutError extends Error { constructor(message?: string) } ``` #### Real-World: API Call with Timeout ```typescript import { timeout } from '@ontologyio/receta/async' import { unwrapOr, mapErr, isErr } from '@ontologyio/receta/result' import * as R from 'remeda' const fetchWithTimeout = async (url: string) => { const result = await timeout( fetch(url).then(r => r.json()), 5000 ) return R.pipe( result, mapErr(error => { logger.warn(`Request to ${url} failed: ${error.message}`) return error }), unwrapOr({ error: 'Request timed out' }) ) } ``` #### Real-World: Multiple Services with Fallback ```typescript import { timeout, retry } from '@ontologyio/receta/async' import { isErr, isOk, match } from '@ontologyio/receta/result' // Try primary service, fall back to secondary const fetchDataResilient = async (query: string) => { // Try primary service with timeout const primaryResult = await timeout( fetch(`https://primary-api.com/search?q=${query}`).then(r => r.json()), 3000 ) if (isOk(primaryResult)) { return primaryResult.value } // Primary failed, try secondary with retry logger.warn('Primary service failed, trying secondary', { error: primaryResult.error }) const secondaryResult = await retry( async () => { const res = await timeout( fetch(`https://secondary-api.com/search?q=${query}`).then(r => r.json()), 5000 ) if (isErr(res)) throw res.error return res.value }, { maxAttempts: 2, delay: 500 } ) return match(secondaryResult, { onOk: (data) => data, onErr: (error) => { logger.error('Both services failed', { primary: primaryResult.error, secondary: error }) return { error: 'All services unavailable' } } }) } ``` #### Comparison: Traditional vs Result | Aspect | Hand-written timeout that throws | `timeout()` | |--------|---------------------------------|-------------| | Error handling | try-catch required | Explicit Result type | | Timeout detection | instanceof check | `isErr` | | Composability | Limited | Full Result API | | Type safety | ❌ Error type unknown | βœ… `Result` | #### Advanced: Combining Timeout and Retry with Result ```typescript import { retry, timeout } from '@ontologyio/receta/async' import { isErr, isOk } from '@ontologyio/receta/result' // Each retry attempt has its own timeout const resilientFetch = async (url: string) => { return retry( async () => { const res = await timeout( fetch(url).then(r => r.json()), 3000 ) // Convert timeout Result to exception for retry logic if (isErr(res)) throw res.error return res.value }, { maxAttempts: 3, delay: 500, onRetry: (error, attempt, delay) => { logger.warn(`Retry ${attempt} after timeout`, { url, delay }) } } ) } // Usage const result = await resilientFetch('https://api.example.com/data') if (isOk(result)) { console.log('Data:', result.value) } else { console.error('Failed after retries:', result.error) } ``` #### Real-World: Microservice Circuit Breaker with Result ```typescript import { timeout, retry } from '@ontologyio/receta/async' import { type Result, ok, err, isErr, isOk } from '@ontologyio/receta/result' class ServiceClient { private circuitOpen = false private failures = 0 private readonly threshold = 5 async request( endpoint: string, timeoutMs: number = 5000 ): Promise> { // Check circuit breaker if (this.circuitOpen) { return err('Circuit breaker open - service unavailable') } const result = await retry( async () => { const res = await timeout( fetch(endpoint).then(r => r.json()), timeoutMs ) if (isErr(res)) throw res.error return res.value as T }, { maxAttempts: 2, delay: 500 } ) if (isOk(result)) { // Reset on success this.failures = 0 return ok(result.value) } // Track failures this.failures++ if (this.failures >= this.threshold) { this.circuitOpen = true setTimeout(() => { this.circuitOpen = false this.failures = 0 }, 30000) // Reset after 30s } return err(`Service error: ${result.error.lastError}`) } } // Usage const client = new ServiceClient() const result = await client.request('/api/users/123', 3000) if (isOk(result)) { console.log('User:', result.value) } else { console.error('Error:', result.error) // No exception thrown - safe to continue } ``` --- ## Combined Patterns ### Retry with Timeout Combine retry and timeout for maximum resilience. `retry` only runs again when the function throws, and `timeout` reports a timeout as an `Err`, so `unwrap` turns that `Err` back into a throw: ```typescript import { retry, timeout } from '@ontologyio/receta/async' import { unwrap } from '@ontologyio/receta/result' // Each retry attempt has a timeout const resilientFetch = async (url: string) => { return retry( async () => { // Each attempt times out after 5 seconds return unwrap(await timeout( fetch(url).then(r => r.json()), 5000 )) }, { maxAttempts: 3, delay: 1000, } ) } // More sophisticated: increase timeout with each retry const adaptiveRetry = async (url: string) => { let attempt = 0 return retry( async () => { attempt++ const timeoutMs = 3000 + (attempt * 2000) // 5s, 7s, 9s return unwrap(await timeout( fetch(url).then(r => r.json()), timeoutMs )) }, { maxAttempts: 3, delay: 1000, } ) } ``` ### Circuit Breaker Pattern Prevent cascading failures by stopping requests after repeated failures: ```typescript import { timeout } from '@ontologyio/receta/async' import { unwrap } from '@ontologyio/receta/result' class CircuitBreaker { private failures = 0 private lastFailureTime = 0 private state: 'closed' | 'open' | 'half-open' = 'closed' constructor( private threshold: number = 5, private timeout: number = 60000, // 1 minute private resetTimeout: number = 30000 // 30 seconds ) {} async execute(operation: () => Promise): Promise { // Check if circuit is open if (this.state === 'open') { const timeSinceFailure = Date.now() - this.lastFailureTime if (timeSinceFailure < this.resetTimeout) { throw new Error('Circuit breaker is open') } // Try to close circuit this.state = 'half-open' } try { // Execute with timeout const result = unwrap(await timeout(operation(), this.timeout)) // Success - reset failures if (this.state === 'half-open') { this.state = 'closed' this.failures = 0 } return result } catch (error) { this.failures++ this.lastFailureTime = Date.now() // Open circuit if threshold exceeded if (this.failures >= this.threshold) { this.state = 'open' } throw error } } } // Usage const breaker = new CircuitBreaker() const fetchWithBreaker = async (url: string) => { return breaker.execute(async () => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) } ``` ### Retry with Rate Limit Handling Handle API rate limits intelligently. `retry` doesn't wait for `onRetry`, so honour the limit inside the function, before it throws: ```typescript import { retry, sleep } from '@ontologyio/receta/async' const fetchWithRateLimit = async (url: string) => { return retry( async () => { const res = await fetch(url) // Check rate limit headers if (res.status === 429) { const resetTime = res.headers.get('X-RateLimit-Reset') const retryAfter = res.headers.get('Retry-After') // Wait for rate limit reset if available if (retryAfter) { await sleep(parseInt(retryAfter) * 1000) } else if (resetTime) { await sleep(Math.max(0, parseInt(resetTime) * 1000 - Date.now())) } throw new Error('Rate limited') } if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3, shouldRetry: (error, attempt) => { // Only retry rate limit errors return error instanceof Error && error.message === 'Rate limited' } } ) } ``` --- ## Real-World Examples ### Fetching from Unreliable APIs ```typescript import { retry, timeout } from '@ontologyio/receta/async' import { unwrap } from '@ontologyio/receta/result' // Third-party API that's often slow or down const fetchWeatherData = async (city: string) => { return retry( async () => { return unwrap(await timeout( fetch(`https://api.weather.com/v1/current?city=${city}`).then(async res => { if (!res.ok) { const error: any = new Error(`Weather API error: ${res.status}`) error.status = res.status throw error } return res.json() }), 10000 // 10 second timeout per attempt )) }, { maxAttempts: 3, delay: 2000, backoff: 2, shouldRetry: (error, attempt) => { // Don't retry client errors if (error instanceof Error && 'status' in error) { const status = (error as any).status if (status >= 400 && status < 500) return false } // Retry timeouts and server errors return true }, onRetry: (error, attempt, delay) => { console.warn(`Weather API retry ${attempt}, waiting ${delay}ms`, { city, error: error instanceof Error ? error.message : String(error), }) } } ) } ``` ### Database Connection with Retry ```typescript import { retry, type RetryError } from '@ontologyio/receta/async' import { type Result, unwrap } from '@ontologyio/receta/result' import { createConnection, Connection } from 'mysql2/promise' class DatabasePool { private connection: Connection | null = null async getConnection(): Promise { if (this.connection) return this.connection const result = await retry( async () => { console.log('Attempting database connection...') return createConnection({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }) }, { maxAttempts: 5, delay: 1000, backoff: 2, maxDelay: 10000, onRetry: (error, attempt, delay) => { console.error(`Database connection failed (attempt ${attempt}), retrying in ${delay}ms...`, { error: error instanceof Error ? error.message : String(error), }) } } ) // Nothing works without a database, so fail loudly here this.connection = unwrap(result) return this.connection } async query(sql: string, params?: any[]): Promise> { const conn = await this.getConnection() return retry( async () => { const [rows] = await conn.execute(sql, params) return rows as T }, { maxAttempts: 3, delay: 100, shouldRetry: (error) => { // Retry on connection errors return error instanceof Error && (error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) } } ) } } ``` ### S3 Upload with Retry ```typescript import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' import { retry, timeout } from '@ontologyio/receta/async' import { unwrap } from '@ontologyio/receta/result' const s3Client = new S3Client({ region: 'us-east-1' }) const uploadToS3 = async ( bucket: string, key: string, body: Buffer | ReadableStream, contentType: string ) => { return retry( async () => { return unwrap(await timeout( s3Client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType, })), 60000 // 1 minute timeout for uploads )) }, { maxAttempts: 3, delay: 2000, backoff: 2, shouldRetry: (error, attempt) => { // Retry on network errors and throttling if (error instanceof Error) { const message = error.message.toLowerCase() return message.includes('network') || message.includes('timed out') || message.includes('throttl') || message.includes('slowdown') } return false }, onRetry: (error, attempt, delay) => { console.warn(`S3 upload retry ${attempt} for ${key}`, { error: error instanceof Error ? error.message : String(error), delay, }) } } ) } // Usage const result = await uploadToS3( 'my-bucket', 'uploads/avatar.jpg', fileBuffer, 'image/jpeg' ) ``` ### Webhook Delivery with Timeout and Retry ```typescript import { mapAsync, retry, timeout, TimeoutError } from '@ontologyio/receta/async' import { partition, unwrap, unwrapOr } from '@ontologyio/receta/result' interface WebhookPayload { event: string data: any timestamp: number } interface WebhookEndpoint { url: string secret: string } const deliverWebhook = async ( endpoint: WebhookEndpoint, payload: WebhookPayload ) => { return retry( async () => { return unwrap(await timeout( fetch(endpoint.url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Secret': endpoint.secret, 'X-Webhook-Event': payload.event, }, body: JSON.stringify(payload), }).then(async res => { if (!res.ok) { throw new Error(`Webhook delivery failed: HTTP ${res.status}`) } return res }), 10000 // 10 second timeout for webhook delivery )) }, { maxAttempts: 3, delay: 5000, // 5 second initial delay backoff: 2, shouldRetry: (error, attempt) => { // Don't retry on client errors (4xx) if (error instanceof Error && error.message.includes('HTTP 4')) { return false } // Retry on timeouts and server errors return true }, onRetry: (error, attempt, delay) => { // Log failed webhook attempts console.error(`Webhook delivery failed (attempt ${attempt})`, { url: endpoint.url, event: payload.event, error: error instanceof Error ? error.message : String(error), nextRetry: `${delay}ms`, }) // Store failed attempt for monitoring webhookMetrics.recordFailure({ url: endpoint.url, event: payload.event, attempt, error: error instanceof TimeoutError ? 'timeout' : 'error', }) } } ) } // Batch webhook delivery const deliverWebhooks = async ( endpoints: WebhookEndpoint[], payload: WebhookPayload ) => { // deliverWebhook returns a Result, so the map itself can't fail const outcome = await mapAsync(endpoints, (endpoint) => deliverWebhook(endpoint, payload) ) const results = unwrapOr(outcome, []) const [delivered, failed] = partition(results) console.log(`Webhook delivery: ${delivered.length}/${endpoints.length} successful`) if (failed.length > 0) { console.error('Failed webhooks:', failed.map(({ lastError }) => lastError)) } return { successful: delivered.length, failed: failed.length, results, } } ``` ### Microservice Communication ```typescript import { retry, timeout, type RetryError } from '@ontologyio/receta/async' import { isOk, type Result, unwrap } from '@ontologyio/receta/result' class ServiceClient { constructor( private baseUrl: string, private serviceName: string ) {} async request( endpoint: string, options: RequestInit = {} ): Promise> { const url = `${this.baseUrl}${endpoint}` return retry( async () => { return unwrap(await timeout( fetch(url, { ...options, headers: { 'Content-Type': 'application/json', 'X-Service-Name': this.serviceName, ...options.headers, }, }).then(async res => { if (!res.ok) { const error: any = new Error( `Service ${this.serviceName} error: ${res.status}` ) error.status = res.status error.service = this.serviceName throw error } return (await res.json()) as T }), 5000 // 5 second timeout per request )) }, { maxAttempts: 3, delay: 1000, backoff: 1.5, shouldRetry: (error, attempt) => { if (error instanceof Error && 'status' in error) { const status = (error as any).status // Don't retry 4xx errors except 429 (rate limit) if (status >= 400 && status < 500 && status !== 429) { return false } } return true }, onRetry: (error, attempt, delay) => { console.warn(`Service ${this.serviceName} retry ${attempt}`, { endpoint, error: error instanceof Error ? error.message : String(error), delay, }) // Report service issues metrics.increment(`service.${this.serviceName}.retry`, { endpoint, attempt: String(attempt), }) } } ) } } // Usage const userService = new ServiceClient('http://users-service', 'user-service') const orderService = new ServiceClient('http://orders-service', 'order-service') const user = await userService.request('/api/users/123', { method: 'GET' }) if (isOk(user)) { const orders = await orderService.request('/api/orders', { method: 'POST', body: JSON.stringify({ userId: user.value.id }), }) } ``` --- ## Best Practices ### 1. Choose Appropriate Retry Limits ```typescript // Don't retry forever const bad = await retry(operation, { maxAttempts: 100, // Too many delay: 100, }) // Use reasonable limits based on operation type const good = await retry(operation, { maxAttempts: 3, // Good for most operations delay: 1000, }) // Higher limits for critical operations const critical = await retry(criticalOperation, { maxAttempts: 5, delay: 2000, maxDelay: 30000, }) ``` ### 2. Use Exponential Backoff for External APIs ```typescript // Good: Exponential backoff reduces server load const data = await retry( () => fetch('https://api.example.com/data'), { maxAttempts: 3, delay: 1000, backoff: 2, // Delays: 1s, 2s, 4s maxDelay: 10000, } ) // Bad: Constant retry hammers the server const data = await retry( () => fetch('https://api.example.com/data'), { maxAttempts: 10, delay: 100, backoff: 1, // Always 100ms - can overload server } ) ``` ### 3. Set Timeouts Based on Operation Type ```typescript // Each of these returns a Result // Fast operations: short timeout const cache = await timeout(redis.get(key), 500) // API calls: medium timeout const data = await timeout(fetch('/api/data'), 5000) // File uploads: long timeout const upload = await timeout(uploadFile(largeFile), 60000) // Background jobs: very long timeout const report = await timeout(generateReport(), 300000) ``` ### 4. Always Check the Result of timeout ```typescript import { timeout } from '@ontologyio/receta/async' import { isOk } from '@ontologyio/receta/result' // Good: Specific timeout handling const loadData = async () => { const result = await timeout(operation(), 5000) return isOk(result) ? result.value : getCachedData() } // Bad: Passing the Result on as if it were the data const loadDataUnchecked = async () => { const data = await timeout(operation(), 5000) return data // Hides that this may be a timeout } ``` ### 5. Log Retry Attempts ```typescript // Good: Monitor retry patterns const data = await retry(operation, { maxAttempts: 3, onRetry: (error, attempt, delay) => { logger.warn('Retry attempt', { operation: 'fetchUserData', attempt, delay, error: error instanceof Error ? error.message : String(error), }) } }) // Even better: Send metrics const data = await retry(operation, { maxAttempts: 3, onRetry: (error, attempt, delay) => { metrics.increment('operation.retry', { operation: 'fetchUserData', attempt: String(attempt), }) } }) ``` ### 6. Prefer Result Pattern for Error Handling ```typescript import { retry, timeout } from '@ontologyio/receta/async' import { type Result, isErr, isOk, mapErr } from '@ontologyio/receta/result' // Good: Result pattern - errors as values const fetchUserSafe = async (id: string): Promise> => { const result = await retry( async () => { const res = await timeout( fetch(`/api/users/${id}`).then(r => r.json()), 5000 ) if (isErr(res)) throw res.error return res.value }, { maxAttempts: 3 } ) return mapErr(result, error => String(error.lastError)) } // Caller doesn't need try-catch const result = await fetchUserSafe('123') if (isOk(result)) { console.log('User:', result.value) } else { console.error('Failed:', result.error) } // Bad: Throwing the error again means try-catch everywhere const fetchUserUnsafe = async (id: string): Promise => { const result = await retry( () => fetch(`/api/users/${id}`).then(r => r.json()), { maxAttempts: 3 } ) if (isErr(result)) throw result.error return result.value } ``` **Benefits of Result Pattern:** - βœ… No try-catch needed at call sites - βœ… Errors visible in function signature - βœ… Composable with other Result operations - βœ… Full error context preserved - βœ… Type-safe error handling --- ## Common Mistakes ### 1. Not Limiting Retry Attempts ```typescript // Bad: Infinite retry loop const badRetry = async () => { while (true) { try { return await operation() } catch { await sleep(1000) // Continues forever! } } } // Good: Use retry with limits const goodRetry = async () => { return retry(operation, { maxAttempts: 3 }) } ``` ### 2. Retrying Non-Idempotent Operations ```typescript // Bad: Retrying payment processing can charge multiple times const badPayment = async (userId: string, amount: number) => { return retry( () => stripe.charges.create({ customer: userId, amount }), { maxAttempts: 3 } ) } // Good: Make operation idempotent with request ID const goodPayment = async (userId: string, amount: number) => { const idempotencyKey = `payment-${userId}-${Date.now()}` return retry( () => stripe.charges.create({ customer: userId, amount, idempotency_key: idempotencyKey // Prevents duplicate charges }), { maxAttempts: 3 } ) } ``` ### 3. Too Short Timeouts ```typescript // Bad: Timeout is too short for the operation const badTimeout = await timeout( fetch('/api/generate-report'), // Takes 10+ seconds 1000 // Times out too quickly ) // Good: Timeout matches operation duration const goodTimeout = await timeout( fetch('/api/generate-report'), 30000 // Reasonable for report generation ) ``` ### 4. Ignoring Retry Callbacks ```typescript // Bad: Silent retries make debugging hard const data = await retry(operation, { maxAttempts: 5 }) // Good: Log retries for visibility const data = await retry(operation, { maxAttempts: 5, onRetry: (error, attempt, delay) => { console.warn(`Retry ${attempt}/${5}:`, error) } }) ``` ### 5. Not Using shouldRetry ```typescript // Bad: Retrying client errors wastes time const bad = await retry( async () => { const res = await fetch('/api/data') if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, { maxAttempts: 3 } ) // Will retry on 404, 400, etc. - pointless! // Good: Only retry retryable errors const good = await retry( async () => { const res = await fetch('/api/data') if (!res.ok) { const error: any = new Error(`HTTP ${res.status}`) error.status = res.status throw error } return res.json() }, { maxAttempts: 3, shouldRetry: (error) => { if (error instanceof Error && 'status' in error) { const status = (error as any).status return status >= 500 || status === 429 } return true } } ) ``` ### 6. Retrying Inside Loops ```typescript // Bad: The outer retry does nothing const bad = async (ids: string[]) => { return retry(async () => { return mapAsync(ids, (id) => retry(() => fetch(`/api/items/${id}`))) }) } // The inner retry returns a Result instead of throwing, // so the outer retry never sees a failure // Good: Retry at the appropriate level const good = async (ids: string[]) => { return mapAsync(ids, (id) => retry(() => fetch(`/api/items/${id}`))) } ``` ### 7. Not Handling Timeout vs Operation Errors ```typescript // Bad: A timeout is an Err, so it never reaches the catch try { await timeout(operation(), 5000) } catch (error) { console.error('Failed') // Only the operation's own errors land here } // Good: Put both failures in one Result, then tell them apart const result = flatten( await tryCatchAsync(() => timeout(operation(), 5000)) ) if (isErr(result)) { if (result.error instanceof TimeoutError) { console.error('Operation timed out - might be slow network') } else { console.error('Operation failed:', result.error) } } else { console.log('Success:', result.value) } ``` ### 8. Mixing Exception and Result Patterns ```typescript // Bad: Inconsistent error handling const fetchData = async (url: string) => { const result = await retry(() => fetch(url), { maxAttempts: 3 }) if (isErr(result)) { throw new Error('Fetch failed') // Breaks Result pattern! } return result.value } // Good: Stay consistent with Result pattern const fetchData = async (url: string): Promise> => { const result = await retry( () => fetch(url).then(r => r.json()), { maxAttempts: 3 } ) return mapErr(result, error => `Failed after ${error.attempts} attempts: ${error.lastError}` ) } // Or: Stay consistent with exceptions const fetchData = async (url: string): Promise => { const result = await retry( () => fetch(url).then(r => r.json()), { maxAttempts: 3 } ) return unwrap(result) // Throws, so exceptions propagate } ``` --- ## When to Use Result Pattern `retry()` and `timeout()` always return a Result. Keep working with it when: βœ… Building APIs where callers need to handle errors gracefully βœ… Composing multiple async operations with error handling βœ… You want errors to be visible in function signatures βœ… You need detailed error context (attempts, last error) βœ… Building functional pipelines with `pipe()` Call `unwrap()` on it to get an exception instead when: βœ… Working with existing exception-based code βœ… Simple scripts where try-catch is acceptable βœ… You want operations to fail fast with exceptions βœ… Integration with libraries that expect exceptions **Recommendation:** Prefer Result for application code, and unwrap at the edges of scripts and tools. --- ## Next Steps - **[Concurrency Control](./concurrency.mdx)**: Managing parallel operations - **[Batching and Polling](./batching.mdx)**: Waiting for conditions to be met - **[Real-World Patterns](./patterns.mdx)**: Complete integration examples --- # Choose a module ## Root Decision Tree: Which Module Do I Need? ``` START: What are you trying to do? β”‚ β”œβ”€ Handle operations that can FAIL? β”‚ β”‚ β”‚ β”œβ”€ Need detailed error information? ────────────────────► result/ β”‚ β”‚ β”‚ └─ Just care if value exists or not? ───────────────────► option/ β”‚ β”œβ”€ Work with ASYNC/PROMISE operations? β”‚ β”‚ β”‚ β”œβ”€ Control concurrency or timing? ──────────────────────► async/ β”‚ β”‚ β”‚ └─ Error handling? ─────────────────────────────────────► async/ + result/ β”‚ β”œβ”€ FILTER or TEST values? β”‚ β”‚ β”‚ β”œβ”€ Simple yes/no checks? ───────────────────────────────► predicate/ β”‚ β”‚ β”‚ └─ Collect multiple errors? ────────────────────────────► validation/ β”‚ β”œβ”€ Work with COLLECTIONS (arrays, lists)? β”‚ β”‚ β”‚ β”œβ”€ Basic operations (map, filter)? ─────────────────────► remeda directly β”‚ β”‚ β”‚ β”œβ”€ Hierarchical grouping or nesting? ───────────────────► collection/ β”‚ β”‚ β”‚ β”œβ”€ Change detection or diffing? ────────────────────────► collection/ β”‚ β”‚ β”‚ └─ Pagination? ─────────────────────────────────────────► collection/ β”‚ β”œβ”€ Work with OBJECTS? β”‚ β”‚ β”‚ β”œβ”€ Simple pick/omit/mapValues? ────────────────────────► remeda directly β”‚ β”‚ β”‚ β”œβ”€ Flatten/unflatten nested objects? ───────────────────► object/ β”‚ β”‚ β”‚ β”œβ”€ Safe nested access? ─────────────────────────────────► object/ (getPath) β”‚ β”‚ β”‚ └─ Deep merging or validation? ─────────────────────────► object/ β”‚ β”œβ”€ Work with STRINGS? β”‚ β”‚ β”‚ β”œβ”€ Format/transform (case, slugify)? ───────────────────► string/ β”‚ β”‚ β”‚ β”œβ”€ Validate (email, URL)? ──────────────────────────────► string/isEmail, isUrl β”‚ β”‚ β”‚ └─ Sanitize (HTML escape, trim)? ───────────────────────► string/escapeHtml, trim β”‚ β”œβ”€ Work with NUMBERS? β”‚ β”‚ β”‚ β”œβ”€ Format (currency, percentage, bytes)? ───────────────► number/ β”‚ β”‚ β”‚ β”œβ”€ Validate or clamp ranges? ───────────────────────────► number/ β”‚ β”‚ β”‚ └─ Parse safely from string? ───────────────────────────► number/fromString β”‚ β”œβ”€ OPTIMIZE performance? β”‚ β”‚ β”‚ β”œβ”€ Cache function results? ──────────────────────────────► memo/ β”‚ β”‚ β”‚ └─ Deduplicate async calls? ─────────────────────────────► memo/memoizeAsync β”‚ β”œβ”€ UPDATE nested immutable data? β”‚ β”‚ β”‚ β”œβ”€ Simple property updates? ────────────────────────────► object/setPath β”‚ β”‚ β”‚ └─ Complex compositional updates? ──────────────────────► lens/ β”‚ β”œβ”€ SORT collections? β”‚ β”‚ β”‚ β”œβ”€ Simple ascending/descending? ────────────────────────► remeda sortBy β”‚ β”‚ β”‚ └─ Multi-level or custom sorting? ──────────────────────► compare/ β”‚ └─ Transform or compose FUNCTIONS? β”‚ β”œβ”€ Conditional logic (if/switch)? ─────────────────────► function/ifElse, cond β”‚ β”œβ”€ Compose multiple functions? ────────────────────────► function/compose β”‚ β”œβ”€ Partial application? ───────────────────────────────► function/partial β”‚ └─ Memoize? ───────────────────────────────────────────► function/memoize ``` ## Quick Reference by Problem Type Entries read as `module/function`: `result/tryCatch` is `tryCatch` from `@ontologyio/receta/result`. ### Error Handling - **Known to fail, need error details** β†’ `result/tryCatch`, `result/map`, `result/flatMap` - **Optional/nullable values** β†’ `option/fromNullable`, `option/map` - **Form validation (multiple errors)** β†’ `validation/schema`, `validation/validateAll` ### Async Operations - **Retry failed requests** β†’ `async/retry` - **Timeout promises** β†’ `async/timeout` - **Process array in parallel** β†’ `async/mapAsync` - **Rate limiting** β†’ `async/debounce`, `async/throttle` ### Data Transformation - **Filter arrays** β†’ `predicate/where`, `remeda/filter` - **Transform nested objects** β†’ `object/mapValues`, `lens/over` - **Group hierarchically** β†’ `collection/nest` - **Detect changes** β†’ `collection/diff` ### Formatting - **Currency** β†’ `number/toCurrency` - **Dates** β†’ Use a date library (out of scope for Receta) - **File sizes** β†’ `number/toBytes` - **URLs/slugs** β†’ `string/slugify` ### Type Safety - **Type guards** β†’ `predicate/isString`, `predicate/isNumber`, etc. - **Runtime validation** β†’ `validation/schema`, `object/validateShape` - **Narrowing unions** β†’ `result/match`, `option/match` ## Module Relationships ``` Core Types (Always Available): β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Result β”‚ β”‚ Option β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Validation β”‚ (Like Result, with error accumulation) β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Functional Utilities: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Predicate │─▢│ Where │─▢│ Filter β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Data Operations: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Collectionβ”‚ β”‚ Object β”‚ β”‚ String β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”‚ Lens β”‚ (Immutable updates) β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Performance: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Memo β”‚ β”‚ Async β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Decision Tree Usage Tips 1. **Start broad, narrow down**: Use this root tree first, then read the module's guide 2. **Multiple modules**: Complex problems often need 2-3 modules together 3. **Remeda first**: If Remeda has it, use Remeda. Receta builds on top 4. **Result by default**: When in doubt about error handling, use Result pattern 5. **Option for nullability**: If you'd check `value != null`, use Option instead ## Next Steps For the functions in each module, see: - [Result](./result/index.mdx) - [Option](./option/index.mdx) - [Async](./async/index.mdx) - [Predicate](./predicate/index.mdx) - [Validation](./validation/index.mdx) - [Collection](./collection/index.mdx) - [Object](./object/index.mdx) - [String](./reference/string.mdx) - [Number](./reference/number.mdx) - [Memo](./memo/index.mdx) - [Lens](./lens/index.mdx) - [Compare](./compare/index.mdx) - [Function](./function/index.mdx) --- # Overview Higher-level patterns for working with collections β€” hierarchical grouping, change detection, pagination, safe indexing, and set operations with custom comparators. ## The Problem: Collections Are Hard Working with arrays of data is fundamental to every application, but common operations are surprisingly painful: ```typescript // ❌ Manual hierarchical grouping - brittle and verbose const commentsByUserAndPost: Record> = {} 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 - error-prone 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 - easy to get wrong const page1 = items.slice(0, 20) const hasMore = items.length > 20 // What about cursor-based? Metadata? Edge cases? // ❌ Manual indexing - silent bugs with duplicates const byId: Record = {} for (const user of users) { byId[user.id] = user // Silently overwrites duplicates! } ``` ### Real-World Example: Reddit Comment System Reddit displays comments hierarchically (by user, then by post) with pagination, change detection for live updates, and indexed lookups for performance. The manual approach is hundreds of lines: ```typescript // Traditional Reddit-style comment system class CommentManager { private comments: Comment[] = [] private byUser: Map> = new Map() private cache: Map = new Map() addComments(newComments: Comment[]) { // Manual change detection for (const comment of newComments) { const existing = this.cache.get(comment.id) if (!existing) { // New comment - need to update all indices this.comments.push(comment) this.updateIndices(comment) } else if (JSON.stringify(existing) !== JSON.stringify(comment)) { // Updated comment - need to reindex const idx = this.comments.indexOf(existing) this.comments[idx] = comment this.rebuildIndices() // Expensive! } } } getCommentsByUserAndPost(userId: number, postId: number, page: number) { // Manual nested grouping + pagination if (!this.byUser.has(userId)) return { items: [], hasMore: false } const userComments = this.byUser.get(userId)! if (!userComments.has(postId)) return { items: [], hasMore: false } const comments = userComments.get(postId)! const start = (page - 1) * 10 const items = comments.slice(start, start + 10) return { items, hasMore: start + 10 < comments.length } } private updateIndices(comment: Comment) { // 50+ lines of manual index management... } private rebuildIndices() { // ...and a full pass over every comment } } ``` ## How Successful Products Handle This ### GitHub API (Nested Grouping + Pagination) ```json { "events": { "repo_foo": { "push": [ { "id": 1, "user": "alice", "timestamp": "2024-01-01" }, { "id": 2, "user": "bob", "timestamp": "2024-01-02" } ], "pr": [ { "id": 3, "user": "alice", "timestamp": "2024-01-03" } ] } }, "pagination": { "cursor": "event_123", "has_more": true } } ``` ### Stripe API (Change Detection) ```json { "type": "customer.updated", "data": { "previous_attributes": { "email": "old@example.com" }, "object": { "id": "cus_123", "email": "new@example.com" } } } ``` ### Redux DevTools (Diff Visualization) ```typescript // Shows exactly what changed in your state const stateDiff = { added: [{ type: 'todos', id: 3 }], updated: [{ path: ['todos', 0, 'completed'], before: false, after: true }], removed: [{ type: 'todos', id: 1 }] } ``` ## The Solution: Collection Module Receta's Collection module provides battle-tested patterns: ```typescript import { nest, diff, paginate, paginateCursor, indexByUnique } from '@ontologyio/receta/collection' import { pipe } from 'remeda' // βœ… Reddit-style comment system in 10 lines const commentsByUserAndPost = nest(comments, ['userId', 'postId']) // => { 1: { 10: [comments...], 20: [...] }, 2: { 10: [...] } } // βœ… Change detection like Stripe webhooks const changes = diff(oldUsers, newUsers, u => u.id) // => { added: [...], updated: [{old, new}], removed: [...], unchanged: [...] } // βœ… GitHub-style cursor pagination const page = paginateCursor(events, e => e.id, { cursor: 'evt_123', limit: 10 }) // => { items: [...], nextCursor: 'evt_456', hasMore: true } // βœ… Safe indexing like Redux normalizer const usersById = indexByUnique(users, u => u.id, { onCollision: 'error' }) // => Ok({ user_1: {...}, user_2: {...} }) or Err(DuplicateKeyError) ``` ## Why Collection Over Manual Loops? ### Problem 1: Manual Grouping Doesn't Scale ```typescript // ❌ What happens with 3 levels? 4 levels? N levels? const grouped: Record>> = {} for (const item of items) { if (!grouped[item.category]) grouped[item.category] = {} if (!grouped[item.category][item.subcategory]) { grouped[item.category][item.subcategory] = {} } if (!grouped[item.category][item.subcategory][item.type]) { grouped[item.category][item.subcategory][item.type] = [] } grouped[item.category][item.subcategory][item.type].push(item) } // βœ… Scales to any depth nest(items, ['category', 'subcategory', 'type']) ``` ### Problem 2: Change Detection Loses Context ```typescript // ❌ Just arrays - no context about what changed const added = newItems.filter(isNew) const removed = oldItems.filter(isGone) // Lost: WHICH item changed? What was the old value? // βœ… Full change context const changes = diff(oldItems, newItems, i => i.id) changes.updated.forEach(({ old, new: updated }) => { console.log(`Item ${old.id} changed from ${old.name} to ${updated.name}`) }) ``` ### Problem 3: Pagination Math Is Easy to Get Wrong ```typescript // ❌ Off-by-one errors, edge cases const start = (page - 1) * pageSize const end = start + pageSize const items = allItems.slice(start, end) const hasNext = end < allItems.length // And you still have to build the metadata yourself // βœ… One call, consistent metadata paginate(allItems, { page, pageSize }) // => { items, page, pageSize, total, hasNext, hasPrevious } ``` ### Problem 4: Duplicate Keys Fail Silently ```typescript // ❌ Duplicate keys silently overwrite const byId: Record = {} for (const user of users) { byId[user.id] = user // Lost data if duplicate ID! } // βœ… Explicit error handling with Result const byIdResult = indexByUnique(users, u => u.id) if (isErr(byIdResult)) { console.error(`Duplicate key: ${byIdResult.error.key}`) } ``` ### Problem 5: Set Operations Need Custom Comparators ```typescript // ❌ Doesn't work with objects const merged = [...set1, ...set2] // Duplicates! const uniqueMerged = [...new Set([...set1, ...set2])] // Still duplicates! // βœ… Works with objects + custom equality union(users1, users2, (a, b) => a.id === b.id) intersect(assigned, completed, (a, b) => a.taskId === b.taskId) ``` ## Real-World Use Cases ### 1. Social Media Feed (Hierarchical Grouping) ```typescript // Group posts by user, then by date const feed = pipe( posts, nest(['userId', (p) => p.createdAt.slice(0, 10)]) // Group by day ) // => { user_1: { '2024-01-01': [posts], '2024-01-02': [posts] } } ``` ### 2. E-commerce Order Tracking (Change Detection) ```typescript // Detect order status changes for notifications const changes = diff(oldOrders, newOrders, o => o.id) for (const { old, new: updated } of changes.updated) { if (old.status !== updated.status) { sendNotification(`Order ${updated.id} is now ${updated.status}`) } } ``` ### 3. API Endpoints (Pagination) ```typescript // Offset-based for simple APIs app.get('/api/users', (req, res) => { const result = paginate(users, { page: Number(req.query.page) || 1, pageSize: 20 }) res.json(result) }) // Cursor-based for high-scale APIs app.get('/api/messages', (req, res) => { const result = paginateCursor( messages, m => m.id, { cursor: req.query.cursor, limit: 50 } ) res.json(result) }) ``` ### 4. State Management (Normalization) ```typescript // Redux-style entity normalization const normalize = (entities: T[]) => pipe( entities, indexByUnique(e => e.id, { onCollision: 'last' }), unwrapOr({}) ) const state = { users: normalize(users), posts: normalize(posts), comments: normalize(comments) } ``` ### 5. Access Control (Set Operations) ```typescript // Merge permissions from multiple roles const adminPerms = [{ resource: 'users', action: 'write' }] const moderatorPerms = [{ resource: 'posts', action: 'delete' }] const allPermissions = union( adminPerms, moderatorPerms, (a, b) => a.resource === b.resource && a.action === b.action ) ``` ## Mental Model: Collection as Data Pipeline Think of collections as flowing through a pipeline of transformations: ``` Raw Data β†’ Group β†’ Pick a Group β†’ Paginate β†’ Index β†’ Ready for Use [1000 items] ↓ groupByPath('payment.status') { paid: [100 items], pending: [50 items], ... } ↓ grouped.paid [100 items] ↓ paginate({ page: 1, pageSize: 20 }) { items: [20 items], page: 1, hasNext: true, ... } ↓ page.items, then indexByUnique(i => i.id) Ok({ item_1: {...}, item_2: {...} }) ``` ## When to Use Collection ### βœ… Use Collection when: - Grouping data hierarchically (2+ levels) - Detecting changes between datasets (sync, webhooks) - Implementing pagination (offset or cursor-based) - Creating indexes with duplicate handling - Performing set operations on objects - Building normalized state (Redux, Zustand) - Processing API responses with nested structures ### ❌ Don't use for: - Simple `filter()` or `map()` β€” use Remeda - Single-level grouping β€” use Remeda's `groupBy()` - Flat key-value lookup β€” use plain `Record<>` - Primitive arrays β€” use native `Set` ## What's Next? - **[Grouping](./grouping.mdx)** - `nest()` and `groupByPath()` - **[Change Detection](./change-detection.mdx)** - `diff()` with custom equality - **[Pagination](./pagination.mdx)** - Offset and cursor-based - **[Indexing](../reference/collection.mdx#indexbyunique)** - Safe indexing with `indexByUnique()` - **[Set Operations](../reference/collection.mdx#union)** - `union()`, `intersect()`, `symmetricDiff()` - **[Patterns](./patterns.mdx)** - Production recipes - **[Migration Guide](./migration.mdx)** - From manual operations - **[API Reference](../reference/collection.mdx)** - Complete function reference --- # Change Detection with diff() Detect exactly what changed between two collections - added, updated, removed, and unchanged items. ## Overview Change detection is essential for: - **Sync operations** - Update local state from server - **Webhooks** - Send notifications only for changes - **Optimistic updates** - Reconcile optimistic vs actual state - **Audit logs** - Track what changed and when - **Live updates** - Reflect changes in real-time UIs ## Basic Usage ```typescript import { diff } from '@ontologyio/receta/collection' const oldUsers = [ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'user' } ] const newUsers = [ { id: 1, name: 'Alicia', role: 'admin' }, // name updated { id: 3, name: 'Charlie', role: 'user' } // added // id: 2 removed ] const changes = diff(oldUsers, newUsers, u => u.id) console.log(changes) // { // added: [{ id: 3, name: 'Charlie', role: 'user' }], // updated: [{ // old: { id: 1, name: 'Alice', role: 'admin' }, // new: { id: 1, name: 'Alicia', role: 'admin' } // }], // removed: [{ id: 2, name: 'Bob', role: 'user' }], // unchanged: [] // } ``` ## The DiffResult Type ```typescript interface DiffResult { readonly added: readonly T[] // Items in new but not in old readonly updated: readonly UpdatedItem[] // Items in both with changes readonly removed: readonly T[] // Items in old but not in new readonly unchanged: readonly T[] // Items in both, no changes } interface UpdatedItem { readonly old: T // Previous version readonly new: T // Current version } ``` ## Custom Equality Checking By default, `diff()` compares items with Remeda's `isDeepEqual`. Provide custom equality for better performance or specific logic: ```typescript const changes = diff( oldProducts, newProducts, p => p.id, (old, updated) => old.price === updated.price && old.stock === updated.stock ) // Only reports changes if price OR stock changed // Ignores other field changes (like updatedAt) ``` ### When to Use Custom Equality ```typescript // Default: a deep comparison of every field of every matched pair diff(items, updatedItems, i => i.id) // βœ… Better: Custom equality for specific fields diff( items, updatedItems, i => i.id, (old, updated) => old.status === updated.status && old.priority === updated.priority ) // βœ… Best: For simple value types diff( items, updatedItems, i => i.id, (old, updated) => old.version === updated.version ) ``` ## Real-World: Stripe Webhook Events ```typescript import { diff } from '@ontologyio/receta/collection' interface Customer { id: string email: string name: string metadata: Record } async function syncCustomers(localCustomers: Customer[]) { // Fetch latest from Stripe const stripeCustomers = await stripe.customers.list() const changes = diff(localCustomers, stripeCustomers.data, c => c.id) // Send webhook events for each type of change for (const customer of changes.added) { await webhook.send({ type: 'customer.created', data: { object: customer } }) } for (const { old, new: updated } of changes.updated) { await webhook.send({ type: 'customer.updated', data: { object: updated, previous_attributes: diffAttributes(old, updated) } }) } for (const customer of changes.removed) { await webhook.send({ type: 'customer.deleted', data: { object: customer } }) } return changes } function diffAttributes(old: Customer, updated: Customer) { const attrs: Partial = {} if (old.email !== updated.email) attrs.email = old.email if (old.name !== updated.name) attrs.name = old.name return attrs } ``` ## Real-World: E-commerce Order Tracking ```typescript interface Order { id: string status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' items: { sku: string, quantity: number }[] total: number customerId: string } function trackOrderChanges( previousOrders: Order[], currentOrders: Order[] ) { const changes = diff(previousOrders, currentOrders, o => o.id) // Notify customers of status changes for (const { old, new: updated } of changes.updated) { if (old.status !== updated.status) { sendCustomerNotification(updated.customerId, { orderId: updated.id, oldStatus: old.status, newStatus: updated.status, message: getStatusMessage(updated.status) }) } } // Alert for new orders if (changes.added.length > 0) { sendAdminAlert(`${changes.added.length} new orders received`) } return changes } function getStatusMessage(status: Order['status']) { const messages = { pending: 'Your order is being processed', paid: 'Payment received, preparing shipment', shipped: 'Your order is on the way!', delivered: 'Your order has been delivered', cancelled: 'Your order has been cancelled' } return messages[status] } ``` ## Real-World: Redux Optimistic Updates ```typescript interface Todo { id: string text: string completed: boolean optimistic?: boolean } function reconcileOptimisticState( optimisticTodos: Todo[], serverTodos: Todo[] ) { const changes = diff(optimisticTodos, serverTodos, t => t.id) // Successful optimistic updates (unchanged) const confirmed = changes.unchanged // Failed optimistic updates (removed from server response) const failed = changes.removed.filter(t => t.optimistic) // Server made additional changes const serverUpdated = changes.updated.map(u => u.new) // New items from other clients const fromOthers = changes.added return { todos: [...confirmed, ...serverUpdated, ...fromOthers], failedOptimistic: failed } } ``` ## Real-World: Live Collaboration ```typescript interface Document { id: string content: string version: number lastModifiedBy: string } function handleDocumentUpdates( localDocs: Document[], serverDocs: Document[] ) { const changes = diff( localDocs, serverDocs, d => d.id, (local, server) => local.version === server.version ) // Merge conflicts: local and server both changed const conflicts = changes.updated.filter( ({ old, new: updated }) => old.lastModifiedBy !== updated.lastModifiedBy ) if (conflicts.length > 0) { return { type: 'conflict', conflicts: conflicts.map(c => ({ documentId: c.old.id, localVersion: c.old, serverVersion: c.new })) } } // No conflicts: apply server changes return { type: 'success', changes } } ``` ## Detecting Specific Field Changes ```typescript import { mask } from '@ontologyio/receta/object' function detectFieldChanges>( old: T, updated: T, fields: (keyof T)[] ): Partial { const changed: Partial = {} for (const field of fields) { if (old[field] !== updated[field]) { changed[field] = old[field] } } return changed } // Usage with diff const changes = diff(oldUsers, newUsers, u => u.id) for (const { old, new: updated } of changes.updated) { const fieldChanges = detectFieldChanges(old, updated, ['email', 'role']) if (Object.keys(fieldChanges).length > 0) { auditLog.record({ userId: updated.id, changedFields: fieldChanges, newValues: mask(updated, Object.keys(fieldChanges)) }) } } ``` ## Performance Optimization ### Time Complexity `diff()` is O(n + m) where n and m are the sizes of the old and new collections: 1. Creates indices: O(n) + O(m) 2. Iterates both sets once: O(n + m) ### Optimization Tips ```typescript // Default: deep-compares every field diff(items, updated, i => i.id) // βœ… Better: Compare specific fields diff( items, updated, i => i.id, (a, b) => a.version === b.version ) // βœ… Best: Compare primitive version field diff( items, updated, i => i.id, (a, b) => a.updatedAt === b.updatedAt // Compare timestamps ) ``` ## Common Patterns ### Pattern 1: Notify Only Updated ```typescript const { updated } = diff(old, current, getId) updated.forEach(({ old, new: item }) => notify(item)) ``` ### Pattern 2: Batch Changes ```typescript const changes = diff(old, current, getId) if (changes.added.length + changes.updated.length + changes.removed.length > 0) { await batchUpdate({ insert: changes.added, update: changes.updated.map(u => u.new), delete: changes.removed.map(r => r.id) }) } ``` ### Pattern 3: Change Summary ```typescript const summarizeChanges = (changes: DiffResult) => ({ totalChanges: changes.added.length + changes.updated.length + changes.removed.length, breakdown: { created: changes.added.length, modified: changes.updated.length, deleted: changes.removed.length, stable: changes.unchanged.length } }) console.log(summarizeChanges(changes)) // { totalChanges: 5, breakdown: { created: 2, modified: 2, deleted: 1, stable: 10 } } ``` ### Pattern 4: Audit Trail ```typescript function createAuditEntries( changes: DiffResult, userId: string ) { const entries: AuditEntry[] = [] for (const item of changes.added) { entries.push({ action: 'create', entityId: item.id, by: userId, timestamp: new Date(), data: item }) } for (const { old, new: updated } of changes.updated) { entries.push({ action: 'update', entityId: updated.id, by: userId, timestamp: new Date(), before: old, after: updated }) } for (const item of changes.removed) { entries.push({ action: 'delete', entityId: item.id, by: userId, timestamp: new Date(), data: item }) } return entries } ``` ## Data-Last in Pipe ```typescript import { pipe } from 'remeda' const syncPipeline = pipe( localData, diff(serverData, getId), changes => ({ ...changes, summary: summarizeChanges(changes) }), applyChanges ) ``` ## TypeScript Tips ```typescript // Type inference works automatically const changes = diff(oldUsers, newUsers, u => u.id) // changes: DiffResult // Narrowing updated items changes.updated.forEach(({ old, new: updated }) => { // old: User, updated: User if (old.role !== updated.role) { // Type-safe field access } }) ``` ## Testing ```typescript import { describe, it, expect } from 'vitest' import { diff } from '@ontologyio/receta/collection' describe('Change detection', () => { it('detects all change types', () => { const changes = diff(old, current, i => i.id) expect(changes.added).toHaveLength(1) expect(changes.updated).toHaveLength(1) expect(changes.removed).toHaveLength(1) expect(changes.unchanged).toHaveLength(2) }) it('uses custom equality', () => { const changes = diff( old, current, i => i.id, (a, b) => a.version === b.version ) expect(changes.updated).toHaveLength(0) // Same version }) }) ``` ## Next Steps - **[Pagination](./pagination.mdx)** - Paginate change results - **[Patterns](./patterns.mdx)** - More production recipes - **[API Reference](../reference/collection.mdx)** - Complete API docs --- # 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 ```typescript 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: ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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: ```typescript 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 Case | Function | Example | |----------|----------|---------| | Multiple top-level keys | `nest()` | Group by [category, priority, status] | | Nested object property | `groupByPath()` | Group by 'user.role' | | Dynamic grouping | `nest()` with functions | Group by age ranges | | Single nested path | `groupByPath()` | 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 ```typescript 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([userId, postId], grouped), []) } // βœ… Better: Group once, reuse const commentsByUserAndPost = nest(comments, ['userId', 'postId']) function getComments(userId: number, postId: number) { return unwrapOr(getPath([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: ```typescript const comments = unwrapOr(getPath([userId, postId], commentsByUserAndPost), []) ``` ### Pattern 2: Nested Access with Default ```typescript const getNestedValue = ( nested: NestedMap, keys: (string | number)[] ): T[] => unwrapOr(getPath(keys, nested), []) const comments = getNestedValue(grouped, [1, 10]) ``` ### Pattern 3: Transform After Grouping ```typescript 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 ```typescript // nest returns NestedMap, which doesn't track how deep it goes const grouped = nest(items, ['category', 'priority']) // So every level is a union: Item | NestedMap | readonly Item[] const electronics = grouped.electronics ``` ### Custom Type Guards ```typescript function isItemArray(value: T | NestedMap | 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 ```typescript 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 - **[Change Detection](./change-detection.mdx)** - Detect changes with `diff()` - **[Pagination](./pagination.mdx)** - Paginate grouped results - **[Patterns](./patterns.mdx)** - Production recipes --- # 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 ```typescript // ❌ Problems: // Manual hierarchical grouping const commentsByUserAndPost: Record> = {} 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 ```typescript // βœ… 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` 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 ```typescript // Manual grouping by category const productsByCategory: Record = {} 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) ```typescript 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 ```typescript // Group events by repository, then by type const eventsByRepoAndType: Record> = {} 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()` ```typescript import { nest } from '@ontologyio/receta/collection' const eventsByRepoAndType = nest(events, ['repo', 'type']) // => { repo_foo: { push: [...], pr: [...] }, repo_bar: {...} } ``` #### Advanced: Group by Derived Value ```typescript // Group by repo, then by date const eventsByRepoAndDate = nest(events, [ 'repo', e => e.timestamp.slice(0, 10) // Extract date ]) // => { repo_foo: { '2024-01-01': [...], '2024-01-02': [...] } } ``` --- ### Example 3: Change Detection #### Before ```typescript // 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()` ```typescript 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 ```typescript // 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 ```typescript // 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 ```typescript // 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()` ```typescript 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 ```typescript // 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()` ```typescript 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 ```typescript // Manual indexing - silently overwrites duplicates const usersById: Record = {} for (const user of users) { usersById[user.id] = user // Lost data if duplicate! } // Manual deduplication const seen = new Set() 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()` ```typescript 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 ```typescript // 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 ```typescript // 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`. ```typescript 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 ```typescript // Group orders by customer country const ordersByCountry: Record = {} for (const order of orders) { const country = order.customer.address.country if (!ordersByCountry[country]) { ordersByCountry[country] = [] } ordersByCountry[country].push(order) } ``` #### Step 1: Use `groupByPath()` ```typescript import { groupByPath } from '@ontologyio/receta/collection' const ordersByCountry = groupByPath(orders, 'customer.address.country') // => { US: [...], CA: [...], UK: [...] } ``` **Advanced: Multi-Level Nested Path** ```typescript // 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. ```typescript // 1. Find repeated pattern // ❌ Manual grouping everywhere: const manualGroups: Record = {} 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 ```typescript // 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 = (items: T[], page: number) => paginate(items, { page, pageSize: 20 }) ``` ```typescript // 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: ```typescript // βœ… 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 ```typescript // ❌ 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 ```typescript // ❌ 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 ```ts nocheck // ❌ Assumes success: TypeScript rejects this, and it's undefined on Err const byId = indexByUnique(users, u => u.id) console.log(byId.value) ``` ```typescript // βœ… 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 ```typescript // ❌ 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) ```typescript // Can't test grouping logic in isolation const getEventsByRepo = (events: RepoEvent[]) => { const grouped: Record> = {} for (const event of events) { // 20 lines of manual logic... } return grouped } ``` ### After (Easy to Test) ```typescript 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 ```typescript // Before: Manual comment grouping const byUserAndPost: Record> = {} 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 ```typescript // 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 ```typescript // 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Β²) ```typescript // ❌ 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) ```typescript // βœ… 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](../reference/collection.mdx)** - Complete function reference - **[Common Patterns](./patterns.mdx)** - Real-world examples - **[README](./index.mdx)** - Documentation overview --- # Pagination Master both pagination strategies for different use cases. ## When to Use Which? | Strategy | Use Case | Pros | Cons | |----------|----------|------|------| | **Offset** | Admin panels, small datasets | Simple, page numbers | Slow for large data, real-time issues | | **Cursor** | Social feeds, infinite scroll | Fast, real-time safe | No page numbers, complex | ## Offset-Based Pagination with `paginate()` Best for: Admin dashboards, reports, search results with page numbers. ### Basic Usage ```typescript import { paginate } from '@ontologyio/receta/collection' const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })) const page1 = paginate(items, { page: 1, pageSize: 20 }) // { // items: [items 1-20], // page: 1, // pageSize: 20, // total: 100, // hasNext: true, // hasPrevious: false // } ``` ### Real-World: Admin User List ```typescript interface User { id: string email: string role: string createdAt: string } app.get('/admin/users', async (req, res) => { const allUsers = await db.users.findAll() const result = paginate(allUsers, { page: Number(req.query.page) || 1, pageSize: 50 }) res.json({ users: result.items, pagination: { page: result.page, pageSize: result.pageSize, totalPages: Math.ceil(result.total / result.pageSize), totalItems: result.total, hasNext: result.hasNext, hasPrevious: result.hasPrevious } }) }) ``` ## Cursor-Based Pagination with `paginateCursor()` Best for: Social feeds, messaging apps, real-time data, infinite scroll. ### Basic Usage ```typescript import { paginateCursor } from '@ontologyio/receta/collection' const messages = Array.from({ length: 50 }, (_, i) => ({ id: `msg_${i + 1}` })) // First page const page1 = paginateCursor(messages, m => m.id, { limit: 20 }) // { items: [first 20], nextCursor: 'msg_20', hasMore: true } // Next page. Omit the cursor key, or pass undefined, to start at the beginning. const page2 = paginateCursor(messages, m => m.id, { cursor: page1.nextCursor, limit: 20 }) // { items: [messages 21-40], nextCursor: 'msg_40', hasMore: true } ``` On the last page `nextCursor` is `undefined` and `hasMore` is `false`. A cursor that isn't in the list starts again from the beginning. ### Real-World: GitHub-Style API ```typescript interface Event { id: string type: string createdAt: string payload: any } app.get('/api/events', async (req, res) => { const events = await db.events.findAll() const result = paginateCursor( events, e => e.id, { cursor: req.query.cursor as string, limit: Number(req.query.per_page) || 30 } ) res.json({ data: result.items, pagination: { next_cursor: result.nextCursor, has_more: result.hasMore } }) }) ``` ### Real-World: Infinite Scroll ```tsx import { useState } from 'react' // React component with infinite scroll function MessageList() { const [messages, setMessages] = useState([]) const [cursor, setCursor] = useState() const [hasMore, setHasMore] = useState(true) const loadMore = async () => { const response = await fetch(`/api/messages?cursor=${cursor}&limit=20`) const data = await response.json() setMessages(prev => [...prev, ...data.items]) setCursor(data.nextCursor) setHasMore(data.hasMore) } return ( {messages.map(msg => )} ) } ``` ## Comparing Both Approaches ### Performance ```typescript // Offset: Gets slower as page number increases // Page 1: SELECT * FROM items LIMIT 20 OFFSET 0 -- Fast // Page 100: SELECT * FROM items LIMIT 20 OFFSET 2000 -- Slow! // Cursor: Consistent speed // SELECT * FROM items WHERE id > 'cursor' LIMIT 20 -- Always fast ``` ### Real-Time Safety ```typescript // Offset: Items can shift between pages const offsetPage1 = paginate(items, { page: 1, pageSize: 10 }) // New item added at position 5 const offsetPage2 = paginate(items, { page: 2, pageSize: 10 }) // Item 10 from page 1 appears again in page 2! // Cursor: Stable pagination const cursorPage1 = paginateCursor(items, getId, { limit: 10 }) // New item added - doesn't affect next page const cursorPage2 = paginateCursor(items, getId, { cursor: cursorPage1.nextCursor, limit: 10 }) // No duplicates! ``` ## Common Patterns ### Pattern 1: Page Navigation UI ```tsx function PaginationControls({ result }: { result: PaginatedResult }) { const totalPages = Math.ceil(result.total / result.pageSize) return (
Page {result.page} of {totalPages}
) } ``` ### Pattern 2: Load More Button ```tsx function LoadMoreButton({ hasMore, onLoad }: { hasMore: boolean, onLoad: () => void }) { return hasMore ? ( ) : ( No more items ) } ``` ### Pattern 3: Combining with Filters ```typescript const filteredAndPaginated = pipe( items, R.filter(item => item.active), R.sortBy(item => item.createdAt), paginate({ page: 1, pageSize: 20 }) ) ``` ## Next Steps - **[Indexing](../reference/collection.mdx#indexbyunique)** - Safe indexing with collision handling - **[Patterns](./patterns.mdx)** - More production recipes --- # Common Patterns & Production Recipes Real-world patterns from GitHub, Reddit, Stripe, and more. ## Social Media: Reddit-Style Comments ```typescript import { nest, paginate } from '@ontologyio/receta/collection' import { getPath } from '@ontologyio/receta/object' import { unwrapOr as unwrapOptionOr } from '@ontologyio/receta/option' import { pipe } from 'remeda' // Hierarchical comment display const displayComments = (comments: Comment[], userId: number, postId: number) => pipe( comments, nest(['userId', 'postId']), getPath([userId, postId]), unwrapOptionOr([]), paginate({ page: 1, pageSize: 20 }) ) ``` ## E-commerce: Order Tracking ```typescript import { diff } from '@ontologyio/receta/collection' // Detect order status changes for notifications const trackOrderChanges = (oldOrders: Order[], newOrders: Order[]) => { const changes = diff(oldOrders, newOrders, o => o.id) for (const { old, new: updated } of changes.updated) { if (old.status !== updated.status) { sendNotification({ orderId: updated.id, oldStatus: old.status, newStatus: updated.status, customer: updated.customerId }) } } return changes } ``` ## API Design: GitHub-Style Pagination ```typescript import { paginateCursor } from '@ontologyio/receta/collection' // Cursor-based API endpoint app.get('/api/events', (req, res) => { const result = paginateCursor( events, e => e.id, { cursor: req.query.cursor as string, limit: Number(req.query.limit) || 50 } ) res.json({ data: result.items, pagination: { next_cursor: result.nextCursor, has_more: result.hasMore } }) }) ``` ## State Management: Redux Normalization ```typescript import { indexByUnique } from '@ontologyio/receta/collection' import { unwrapOr } from '@ontologyio/receta/result' const normalizeEntities = (entities: T[]) => pipe( entities, indexByUnique(e => e.id, { onCollision: 'last' }), unwrapOr({}) ) const store = { users: normalizeEntities(users), posts: normalizeEntities(posts), comments: normalizeEntities(comments) } ``` ## Access Control: Permission Merging ```typescript import { union } from '@ontologyio/receta/collection' type Permission = { resource: string; action: string } const getUserPermissions = (userId: string) => { const roles = getUserRoles(userId) const permissions: Permission[] = roles.flatMap(role => getRolePermissions(role)) // union checks the second list against what it has kept so far, // so the list to deduplicate goes second return union( [], permissions, (a, b) => a.resource === b.resource && a.action === b.action ) } ``` --- # Overview Build composable, type-safe comparators for sorting complex data structures. No more brittle array.sort((a, b) => ...) callbacks scattered throughout your codebase. ## The Problem: Sorting is Harder Than It Looks Sorting seems simple until you need to: - Sort by multiple fields with different priorities - Handle null/undefined values consistently - Sort strings case-insensitively or with locale awareness - Sort version numbers naturally ("v1.2" before "v1.10") - Reverse sort directions without rewriting logic - Compose sorting logic across your application ### Real-World Example: GitHub Issues Sorting Here's what you typically write: ```typescript interface Issue { status: 'open' | 'closed' priority: 'low' | 'medium' | 'high' createdAt: Date assignee: string | null } // ❌ Brittle, hard to read, easy to get wrong issues.sort((a, b) => { // Sort by status (open first) const statusOrder = { open: 0, closed: 1 } const statusDiff = statusOrder[a.status] - statusOrder[b.status] if (statusDiff !== 0) return statusDiff // Then by priority (high first) const priorityOrder = { high: 0, medium: 1, low: 2 } const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority] if (priorityDiff !== 0) return priorityDiff // Then by created date (oldest first) return a.createdAt.getTime() - b.createdAt.getTime() }) ``` **Problems:** 1. **Not reusable** - Can't extract "sort by priority" for other lists 2. **Error-prone** - Easy to mess up the comparison logic 3. **Hard to read** - Business logic buried in nested conditions 4. **Not composable** - Can't build complex sorts from simple ones 5. **No type safety** - Typos in property names caught only at runtime ### How Successful Products Handle This **GitHub** (actual UI sorting): - Issues can be sorted by status, assignee, labels, milestones, created date, updated date - Multi-level sorting: "Open issues assigned to me, sorted by priority, then by age" - Nulls handling: Unassigned issues grouped separately **Stripe** (transaction lists): - Sort by amount, status, date, customer - Natural sorting for invoice numbers ("INV-2" before "INV-10") - Consistent null handling for optional fields **Vercel** (deployment lists): - Sort by date (most recent first), duration, status - Case-insensitive sorting for branch names - Compose filters + sorts in URL query params All these products need **reusable, composable sorting logic**. ## The Solution: Comparator Builders ```typescript import { ascending, compose, byDate } from '@ontologyio/receta/compare' // βœ… Readable, reusable, composable const statusOrder = { open: 0, closed: 1 } const priorityOrder = { high: 0, medium: 1, low: 2 } issues.sort( compose( ascending(i => statusOrder[i.status]), ascending(i => priorityOrder[i.priority]), byDate(i => i.createdAt) ) ) ``` **Benefits:** 1. **Reusable** - Extract `byPriority = ascending(i => priorityOrder[i.priority])` 2. **Type-safe** - TypeScript catches property name typos 3. **Readable** - Intent is clear: "sort by status, then priority, then date" 4. **Composable** - Build complex sorts from simple building blocks 5. **Testable** - Test individual comparators in isolation ## Why Compare Over `Array.sort()` Callbacks? ### Problem 1: Array.sort() Doesn't Compose ```typescript // ❌ Can't reuse or combine const sortByPrice = (a: Product, b: Product) => a.price - b.price const sortByStock = (a: Product, b: Product) => a.stock - b.stock // How do you combine these? Copy-paste and add if statements? 😒 ``` ```typescript // βœ… Comparators compose naturally import { ascending, compose } from '@ontologyio/receta/compare' const byPrice = ascending((p: Product) => p.price) const byStock = ascending((p: Product) => p.stock) // Compose them! const byPriceThenStock = compose(byPrice, byStock) products.sort(byPriceThenStock) ``` ### Problem 2: Array.sort() Loses Type Information ```typescript // ❌ No type safety items.sort((a, b) => a.createdAd - b.createdAt) // Typo! Runtime error // βœ… TypeScript catches typos import { byDate } from '@ontologyio/receta/compare' items.sort(byDate(i => i.createdAd)) // ❌ Compile error: Property 'createdAd' doesn't exist ``` ### Problem 3: Array.sort() Doesn't Work in Pipelines ```typescript // ❌ Have to break out of the pipe const result = R.pipe( items, R.filter(isActive), // Can't pass a comparator here easily (items) => [...items].sort((a, b) => a.priority - b.priority) ) // βœ… Works seamlessly with Remeda import { ascending } from '@ontologyio/receta/compare' const result = R.pipe( items, R.filter(isActive), R.sort(ascending(i => i.priority)) ) ``` ### Problem 4: Array.sort() Hides Null Handling ```typescript // ❌ A null quietly counts as 0, so it lands among the real values items.sort((a, b) => (a.value ?? 0) - (b.value ?? 0)) // βœ… Say where nulls go: false sorts before true import { ascending, byBoolean, compose } from '@ontologyio/receta/compare' items.sort( compose( byBoolean(i => i.value === null), // Items with no value go last ascending(i => i.value ?? 0) ) ) ``` ## Real-World Use Cases ### 1. Data Tables Sort by multiple columns with different directions: ```typescript import { compose, ascending, descending } from '@ontologyio/receta/compare' interface Employee { department: string name: string salary: number } // Sort by department (asc), then salary (desc) employees.sort( compose( ascending(e => e.department), descending(e => e.salary) ) ) ``` ### 2. API Response Lists Handle complex sorting from query params: ```typescript import { ascending, descending, byDate, compose } from '@ontologyio/receta/compare' interface SearchParams { sortBy: 'date' | 'relevance' | 'price' order: 'asc' | 'desc' } function getSortComparator(params: SearchParams): Comparator { const base = params.sortBy === 'date' ? byDate(p => p.createdAt) : params.sortBy === 'price' ? ascending(p => p.price) : ascending(p => p.relevanceScore) return params.order === 'desc' ? reverse(base) : base } ``` ### 3. File Browsers Natural sorting for file names: ```typescript import { natural, compose, ascending } from '@ontologyio/receta/compare' interface FileEntry { type: 'file' | 'directory' name: string } // Directories first, then natural sort by name files.sort( compose( ascending(f => f.type === 'directory' ? 0 : 1), natural(f => f.name) ) ) // => [dir1/, dir2/, file1.txt, file2.txt, file10.txt] ``` ### 4. Leaderboards Sort players by score, breaking ties by time: ```typescript import { withTiebreaker, descending, ascending } from '@ontologyio/receta/compare' interface Player { name: string score: number completionTime: number } // Highest score first, fastest time breaks ties players.sort( withTiebreaker( descending(p => p.score), ascending(p => p.completionTime) ) ) ``` ### 5. Internationalized Apps Locale-aware string sorting: ```typescript import { localeCompare } from '@ontologyio/receta/compare' const germanCities = ['MΓΌnchen', 'Berlin', 'ZΓΌrich'] germanCities.sort(localeCompare(x => x, 'de-DE')) // => Correctly handles ΓΌ, ΓΆ, Γ€ according to German rules ``` ## Mental Model: Building Blocks Think of comparators as **Lego bricks** for sorting: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Final Comparator β”‚ β”‚ (What you pass to array.sort()) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β–² β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Simple β”‚ β”‚ Combinatorβ”‚ β”‚ Block β”‚ + β”‚ (compose,β”‚ β”‚ β”‚ β”‚ reverse) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ascending() compose() byDate() reverse() natural() nullsFirst() ``` **Simple blocks** do one thing: - `ascending(fn)` - Sort low to high - `byDate(fn)` - Sort dates chronologically - `natural(fn)` - Natural string sorting **Combinators** connect blocks: - `compose(a, b, c)` - Multi-level sorting - `reverse(comparator)` - Flip the order - `nullsFirst(comparator)` - Put null items first **Combine them** to solve complex problems: ```typescript compose( ascending(x => statusOrder[x.status]), reverse(byDate(x => x.createdAt)), ascending(x => x.assignee ?? '') ) ``` ## When to Use Compare βœ… **Use when:** - Sorting objects by multiple fields - Need consistent null/undefined handling - Building reusable sorting utilities - Working with internationalized strings - Sorting version numbers or file names naturally - Combining filters and sorts in pipelines - Need type-safe property access ❌ **Don't use for:** - Simple one-off number sorting (`[3,1,2].sort((a,b) => a - b)` is fine) - Sorting primitives where built-in sort works - When you need custom collation beyond locale support - Performance-critical sorting (though Compare is fast, hand-written may be faster) ## Core Concepts ### 1. Comparator Type ```typescript type Comparator = (a: T, b: T) => number // Returns: // < 0 if a should come before b // 0 if a and b are equal // > 0 if a should come after b ``` All Compare functions either **create** or **modify** comparators. ### 2. Ascending vs Descending ```typescript ascending((x: Item) => x.value) // Low to high: 1, 2, 3 descending((x: Item) => x.value) // High to low: 3, 2, 1 ``` ### 3. Composition ```typescript // Primary sort, then tiebreaker, then second tiebreaker compose( primaryComparator, tiebreakerComparator, secondTiebreakerComparator ) ``` ### 4. Null Handling ```typescript nullsFirst(comparator) // null, null, 1, 2, 3 nullsLast(comparator) // 1, 2, 3, null, null ``` These check the items being sorted, not a field inside them. For a nullable field, see [Sorting by a Nullable Field](./combinators.mdx#sorting-by-a-nullable-field). ### 5. Type-Specific Comparators Different types need different comparisons: - **Dates**: `byDate()` compares timestamps - **Numbers**: `byNumber()` handles NaN and Infinity - **Strings**: `byString()` supports case and locale options - **Booleans**: `byBoolean()` treats false < true ## Comparison with Alternatives ### vs Lodash `_.orderBy` ```typescript // Lodash _.orderBy(users, ['status', 'priority', 'createdAt'], ['asc', 'desc', 'asc']) // ❌ Problems: // - Property names are strings (no type safety) // - Limited to object properties (can't use computed values) // - Order array must match property array // - No composition or reusability // Compare users.sort( compose( ascending(u => u.status), descending(u => u.priority), byDate(u => u.createdAt) ) ) // βœ… Type-safe, composable, works with any extraction function ``` ### vs Native `Intl.Collator` ```typescript // Intl.Collator (good for strings) const collator = new Intl.Collator('de-DE') names.sort((a, b) => collator.compare(a, b)) // ❌ Only works for strings, verbose for objects // Compare (uses String.prototype.localeCompare) names.sort(localeCompare(x => x, 'de-DE')) // βœ… Works with objects, composable, cleaner syntax ``` ### vs Ramda `R.sortBy` ```typescript // Ramda import { sortBy, prop } from 'ramda' sortBy(prop('age'), users) // ❌ Can only sort by one property // ❌ Always ascending // ❌ No type inference // Compare users.sort(ascending(u => u.age)) // βœ… Full TypeScript support // βœ… Composable for multi-level sorting // βœ… More control (ascending/descending/natural/etc) ``` ## Architecture: How It Works ### Extraction Functions All basic comparators take an **extraction function**: ```text ascending((user: User) => user.age) β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Extracts comparable value ``` This separates **what to compare** (the value) from **how to compare** (ascending/descending/natural). ### Composition via Priority `compose()` applies comparators in order, short-circuiting when one returns non-zero: ```typescript compose( comparatorA, // If A says a β‰  b, use A's result comparatorB, // If A says a = b, try B comparatorC // If B also says equal, try C ) ``` ### Null Safety Layer `nullsFirst` and `nullsLast` wrap comparators to handle null items before delegating. `nullsFirst(innerComparator)` behaves like this: ```typescript (a, b) => { if (a == null && b == null) return 0 if (a == null) return -1 // a comes first if (b == null) return 1 // b comes first return innerComparator(a, b) } ``` ## What's Next? - **[Basic Comparators](./basic-comparators.mdx)** - `ascending`, `descending`, `natural`, `byKey` - **[Combinators](./combinators.mdx)** - `compose`, `reverse`, `nullsFirst`, `nullsLast` - **[Type-Specific](./type-specific.mdx)** - `byDate`, `byNumber`, `byString`, `byBoolean` - **[Advanced](./advanced.mdx)** - `caseInsensitive`, `localeCompare` - **[Patterns & Recipes](./patterns.mdx)** - Real-world solutions - **[Migration Guide](./migration.mdx)** - From `Array.sort()` to Compare - **[API Reference](../reference/compare.mdx)** - Complete function catalog --- # Advanced Comparators Advanced comparators provide specialized string sorting for case-insensitive and locale-aware scenarios. ## Overview | Function | Purpose | Use Case | |----------|---------|----------| | `caseInsensitive(fn)` | Ignore case when sorting | Filenames, usernames, search results | | `localeCompare(fn, locale)` | Culture-aware sorting | International names, multilingual content | ## caseInsensitive() Sorts strings ignoring case differences (Alice = alice = ALICE). ### Signature ```typescript title="Signature" function caseInsensitive(fn: (value: T) => string): Comparator ``` ### The Problem It Solves Default string sorting is case-sensitive, with uppercase coming before lowercase: ```typescript // ❌ Case-sensitive (default) ['banana', 'Apple', 'cherry', 'BANANA'].sort() // => ['Apple', 'BANANA', 'banana', 'cherry'] // ^ Uppercase first (not intuitive for users) ``` ```typescript // βœ… Case-insensitive import { caseInsensitive } from '@ontologyio/receta/compare' ['banana', 'Apple', 'cherry', 'BANANA'].sort(caseInsensitive(x => x)) // => ['Apple', 'banana', 'BANANA', 'cherry'] // ^ Alphabetical regardless of case ``` ### Examples #### File Browser ```typescript import { caseInsensitive } from '@ontologyio/receta/compare' const files = [ 'README.md', 'index.ts', 'App.tsx', 'package.json', 'API.md' ] // Case-sensitive (default) - uppercase first files.sort() // => ['API.md', 'App.tsx', 'README.md', 'index.ts', 'package.json'] // Case-insensitive - truly alphabetical files.sort(caseInsensitive(x => x)) // => ['API.md', 'App.tsx', 'index.ts', 'package.json', 'README.md'] ``` #### User Directory ```typescript interface User { username: string displayName: string } const users: User[] = [ { username: 'alice', displayName: 'Alice Smith' }, { username: 'BOB', displayName: 'Bob Jones' }, { username: 'Charlie', displayName: 'Charlie Brown' } ] // Sort by username (case-insensitive) users.sort(caseInsensitive(u => u.username)) // => [alice, BOB, Charlie] (alphabetical) ``` #### Search Results ```typescript interface SearchResult { title: string relevance: number } // Sort alphabetically (case-insensitive) after filtering const results = searchResults .filter(r => r.relevance > 0.5) .sort(caseInsensitive(r => r.title)) ``` #### Tags/Labels ```typescript interface Tag { name: string count: number } const tags: Tag[] = [ { name: 'TypeScript', count: 50 }, { name: 'javascript', count: 100 }, { name: 'REACT', count: 75 } ] // Alphabetical tag list (case-insensitive) tags.sort(caseInsensitive(t => t.name)) // => [javascript, REACT, TypeScript] ``` ### Real-World: GitHub Repository List ```typescript interface Repository { name: string stars: number language: string } // Sort repos by name (case-insensitive) repos.sort(caseInsensitive(r => r.name)) // Multi-level: language (case-insensitive), then stars (descending) repos.sort( compose( caseInsensitive(r => r.language), descending(r => r.stars) ) ) ``` ### Real-World: Dropdown Options ```typescript interface SelectOption { label: string value: string } // Sort dropdown options alphabetically (case-insensitive) options.sort(caseInsensitive(o => o.label)) ``` ### When to Use βœ… **Use caseInsensitive() when:** - Sorting user-facing lists (files, usernames, titles) - Search results should appear alphabetically - Case doesn't carry meaning (README.md = readme.md) - Better UX than case-sensitive sorting ❌ **Don't use when:** - Case distinctions matter (API vs api vs Api might be different) - Sorting code identifiers (case-sensitive language) - Performance is critical (slight overhead vs regular sort) --- ## localeCompare() Sorts strings according to language-specific rules and cultural conventions. ### Signature ```typescript function localeCompare( fn: (value: T) => string, locale: string ): Comparator ``` ### The Problem It Solves Different languages have different sorting rules: - **German**: Γ€, ΓΆ, ΓΌ have specific positions - **French**: accented characters (Γ©, Γ¨, Γͺ) sort distinctly - **Spanish**: Γ± comes after n - **Swedish**: Γ₯, Γ€, ΓΆ come AFTER z - **Japanese**: hiragana has specific ordering ```typescript // ❌ ASCII sorting (wrong for non-English) ['ZΓΌrich', 'Berlin', 'Γ…arhus'].sort() // => Depends on implementation, often wrong // βœ… Locale-aware sorting import { localeCompare } from '@ontologyio/receta/compare' ['ZΓΌrich', 'Berlin', 'Γ…arhus'].sort(localeCompare(x => x, 'de-DE')) // => Correct German ordering ``` ### Examples #### German Names ```typescript import { localeCompare } from '@ontologyio/receta/compare' const germanCities = [ 'ZΓΌrich', 'Berlin', 'MΓΌnchen', 'Hamburg' ] // German locale (handles ΓΌ, ΓΆ correctly) germanCities.sort(localeCompare(x => x, 'de-DE')) // => Correct German alphabetical order ``` #### French Accents ```typescript const frenchNames = [ 'Γ‰tienne', 'Eric', 'Γ‰mile', 'Γ‰ric' ] // French locale (handles Γ©, Γ¨, Γͺ correctly) frenchNames.sort(localeCompare(x => x, 'fr-FR')) // => Correct French ordering (Γ© variations grouped correctly) ``` #### Spanish Γ± ```typescript const spanishWords = [ 'maΓ±ana', 'manana', 'manzana', 'mano' ] // Spanish locale (Γ± comes after n) spanishWords.sort(localeCompare(x => x, 'es-ES')) // => [mano, manana, manzana, maΓ±ana] ``` #### Swedish Γ…, Γ„, Γ– ```typescript const swedishWords = [ 'ΓΆga', 'Γ€rlig', 'adam', 'Γ₯sa' ] // Swedish locale (Γ₯, Γ€, ΓΆ come after z) swedishWords.sort(localeCompare(x => x, 'sv-SE')) // => [adam, Γ₯sa, Γ€rlig, ΓΆga] ``` #### Japanese Hiragana ```typescript const japaneseWords = [ 'さくら', // sakura 'あさひ', // asahi 'γŸγ‘γ—' // takeshi ] // Japanese locale japaneseWords.sort(localeCompare(x => x, 'ja-JP')) // => Correct hiragana ordering ``` ### Real-World: International User Directory ```typescript interface User { name: string country: string locale: string } // Sort users by their locale function sortUsersByLocale(users: User[]): User[] { // Group by locale, sort within each group const grouped = R.groupBy(users, u => u.locale) return Object.entries(grouped).flatMap(([locale, users]) => users.sort(localeCompare(u => u.name, locale)) ) } ``` ### Real-World: Multilingual Product Catalog ```typescript interface Product { name: string nameTranslations: Record } function sortProductsForLocale( products: Product[], locale: string ): Product[] { return products.sort( localeCompare(p => p.nameTranslations[locale] || p.name, locale) ) } // German users see German-sorted names sortProductsForLocale(products, 'de-DE') // French users see French-sorted names sortProductsForLocale(products, 'fr-FR') ``` ### Real-World: Address Book ```typescript interface Contact { firstName: string lastName: string country: string } function sortContacts(contacts: Contact[], userLocale: string) { return contacts.sort( compose( localeCompare(c => c.lastName, userLocale), localeCompare(c => c.firstName, userLocale) ) ) } ``` ### Locale Codes Common BCP 47 language tags: | Locale | Language | Notes | |--------|----------|-------| | `en-US` | English (US) | Default for most apps | | `en-GB` | English (UK) | Different from US | | `de-DE` | German (Germany) | Handles Γ€, ΓΆ, ΓΌ | | `fr-FR` | French (France) | Handles accents | | `es-ES` | Spanish (Spain) | Handles Γ± | | `it-IT` | Italian | | | `pt-BR` | Portuguese (Brazil) | | | `zh-CN` | Chinese (Simplified) | | | `ja-JP` | Japanese | | | `ko-KR` | Korean | | | `ar-SA` | Arabic (Saudi Arabia) | Right-to-left | | `sv-SE` | Swedish | Γ₯, Γ€, ΓΆ after z | | `da-DK` | Danish | | | `nl-NL` | Dutch | | | `pl-PL` | Polish | | ### When to Use βœ… **Use localeCompare() when:** - Building international/multilingual applications - Sorting user-generated content in their language - Displaying names, addresses, or text to users - Correct cultural sorting matters ❌ **Don't use when:** - All content is ASCII/English only - Sorting code/technical identifiers - Performance is critical (locale comparison is slower) - Default sorting is acceptable --- ## Combining Advanced Comparators ### Example: Multilingual File Browser ```typescript interface FileEntry { name: string type: 'directory' | 'file' locale: string } function sortFiles(files: FileEntry[], userLocale: string) { return files.sort( compose( ascending(f => f.type === 'directory' ? 0 : 1), // Directories first localeCompare(f => f.name, userLocale) // Then locale-aware by name ) ) } ``` ### Example: International Leaderboard ```typescript interface Player { name: string score: number country: string } // Sort by score, then name (locale-aware) function sortLeaderboard(players: Player[], locale: string) { return players.sort( compose( descending(p => p.score), localeCompare(p => p.name, locale) ) ) } ``` ## Performance Considerations ### Case-Insensitive ```typescript // caseInsensitive() converts to lowercase internally // Slight overhead, but usually negligible // For very large lists, consider: const normalized = items.map(item => ({ ...item, lowerName: item.name.toLowerCase() })) normalized.sort(ascending(x => x.lowerName)) ``` ### Locale-Aware ```typescript // localeCompare() is slower than simple string comparison // Consider caching if sorting frequently: const collator = new Intl.Collator('de-DE') items.sort((a, b) => collator.compare(a.name, b.name)) // Or use localeCompare() for clarity (uses String.prototype.localeCompare) items.sort(localeCompare(x => x.name, 'de-DE')) ``` ## Comparison: When to Use Which | Scenario | Use | Example | |----------|-----|---------| | English filenames | `caseInsensitive()` | README.md, app.tsx | | International names | `localeCompare()` | MΓΌller, JosΓ©, 佐藀 | | Tags/labels (EN) | `caseInsensitive()` | JavaScript, REACT | | Multilingual content | `localeCompare()` | Product names, addresses | | User directory (EN) | `caseInsensitive()` | alice, Bob, CHARLIE | | User directory (i18n) | `localeCompare()` | AndrΓ©, BjΓΆrn, Ε½eljko | ## Next Steps - **[Patterns & Recipes](./patterns.mdx)** - Real-world solutions and recipes - **[Migration Guide](./migration.mdx)** - From Array.sort() to Compare - **[API Reference](../reference/compare.mdx)** - Complete function catalog --- # Basic Comparators Basic comparators are the foundation of the Compare module. They create simple, reusable sorting functions for common scenarios. ## Overview | Function | Purpose | Example | |----------|---------|---------| | `ascending(fn)` | Sort low to high | Numbers, dates, priority scores | | `descending(fn)` | Sort high to low | Latest first, highest score | | `natural(fn)` | Natural string order | File names, version numbers | | `byKey(key)` | Sort by object property | Quick property-based sorting | ## ascending() Sorts values in ascending order (low to high) based on an extracted value. ### Signature ```typescript function ascending( fn: (value: T) => C ): Comparator ``` ### When to Use - Sorting numbers from smallest to largest - Sorting strings alphabetically (A-Z) - Sorting dates chronologically (oldest first) - Default ordering for most lists ### Examples #### Sorting Numbers ```typescript import { ascending } from '@ontologyio/receta/compare' const prices = [99.99, 49.99, 149.99, 29.99] prices.sort(ascending(x => x)) // => [29.99, 49.99, 99.99, 149.99] ``` #### Sorting Objects by Number Property ```typescript interface Product { name: string price: number stock: number } const products: Product[] = [ { name: 'Keyboard', price: 80, stock: 15 }, { name: 'Mouse', price: 25, stock: 30 }, { name: 'Monitor', price: 300, stock: 5 } ] // Sort by price (lowest first) products.sort(ascending(p => p.price)) // => [Mouse ($25), Keyboard ($80), Monitor ($300)] // Sort by stock (least in stock first) products.sort(ascending(p => p.stock)) // => [Monitor (5), Keyboard (15), Mouse (30)] ``` #### Sorting Strings ```typescript interface User { username: string email: string } const users: User[] = [ { username: 'charlie', email: 'charlie@example.com' }, { username: 'alice', email: 'alice@example.com' }, { username: 'bob', email: 'bob@example.com' } ] users.sort(ascending(u => u.username)) // => [alice, bob, charlie] ``` #### Sorting Dates ```typescript interface Event { name: string date: Date } const events: Event[] = [ { name: 'Launch', date: new Date('2024-03-01') }, { name: 'Meeting', date: new Date('2024-02-15') }, { name: 'Review', date: new Date('2024-02-20') } ] events.sort(ascending(e => e.date)) // => [Meeting (Feb 15), Review (Feb 20), Launch (Mar 1)] ``` ### Real-World: Stripe Payments ```typescript interface Payment { id: string amount: number createdAt: Date status: string } // Sort payments by amount (smallest first) payments.sort(ascending(p => p.amount)) // Sort by creation date (oldest first) payments.sort(ascending(p => p.createdAt)) ``` --- ## descending() Sorts values in descending order (high to low) based on an extracted value. ### Signature ```typescript function descending( fn: (value: T) => C ): Comparator ``` ### When to Use - "Most recent first" date sorting - "Highest score first" leaderboards - "Most expensive first" price lists - Reverse alphabetical order (Z-A) ### Examples #### Sorting Numbers (Highest First) ```typescript import { descending } from '@ontologyio/receta/compare' const scores = [95, 87, 100, 92] scores.sort(descending(x => x)) // => [100, 95, 92, 87] ``` #### Leaderboard ```typescript interface Player { name: string score: number level: number } const players: Player[] = [ { name: 'Alice', score: 1000, level: 5 }, { name: 'Bob', score: 1500, level: 7 }, { name: 'Charlie', score: 800, level: 4 } ] // Highest score first players.sort(descending(p => p.score)) // => [Bob (1500), Alice (1000), Charlie (800)] ``` #### Most Recent First ```typescript interface BlogPost { title: string publishedAt: Date views: number } const posts: BlogPost[] = [ { title: 'Intro to TypeScript', publishedAt: new Date('2024-01-15'), views: 100 }, { title: 'Advanced Patterns', publishedAt: new Date('2024-02-20'), views: 250 }, { title: 'Best Practices', publishedAt: new Date('2024-01-30'), views: 180 } ] // Most recent posts first posts.sort(descending(p => p.publishedAt)) // => [Advanced Patterns (Feb 20), Best Practices (Jan 30), Intro (Jan 15)] // Most viewed first posts.sort(descending(p => p.views)) // => [Advanced Patterns (250), Best Practices (180), Intro (100)] ``` ### Real-World: GitHub Issues ```typescript interface Issue { number: number title: string comments: number updatedAt: Date } // Most recently updated issues first issues.sort(descending(i => i.updatedAt)) // Most discussed issues first issues.sort(descending(i => i.comments)) ``` --- ## natural() Sorts strings naturally, handling embedded numbers correctly. ### Signature ```typescript title="Signature" function natural(fn: (value: T) => string): Comparator ``` ### The Problem It Solves Standard string sorting treats numbers lexicographically: ```typescript // ❌ Lexicographic (wrong for numbers) ['file10.txt', 'file2.txt', 'file1.txt'].sort() // => ['file1.txt', 'file10.txt', 'file2.txt'] // ↑ Wrong! 10 comes before 2 ``` Natural sorting handles numbers within strings intelligently: ```typescript // βœ… Natural sorting (correct) import { natural } from '@ontologyio/receta/compare' ['file10.txt', 'file2.txt', 'file1.txt'].sort(natural(x => x)) // => ['file1.txt', 'file2.txt', 'file10.txt'] // ↑ Correct! 2 comes before 10 ``` ### When to Use - File names with numbers - Version numbers (v1.2, v1.10, v1.20) - Invoice/order numbers (INV-001, INV-002, INV-010) - Street addresses ("10 Main St", "2 Main St") - Any string containing numbers that should sort numerically ### Examples #### File Names ```typescript import { natural } from '@ontologyio/receta/compare' const files = [ 'image10.png', 'image2.png', 'image1.png', 'image20.png' ] files.sort(natural(x => x)) // => ['image1.png', 'image2.png', 'image10.png', 'image20.png'] ``` #### Version Numbers ```typescript interface Release { version: string date: Date } const releases: Release[] = [ { version: 'v1.10.0', date: new Date('2024-03-01') }, { version: 'v1.2.0', date: new Date('2024-01-15') }, { version: 'v1.20.0', date: new Date('2024-04-10') }, { version: 'v2.0.0', date: new Date('2024-05-01') } ] releases.sort(natural(r => r.version)) // => [v1.2.0, v1.10.0, v1.20.0, v2.0.0] ``` #### Invoice Numbers ```typescript interface Invoice { number: string amount: number } const invoices: Invoice[] = [ { number: 'INV-10', amount: 500 }, { number: 'INV-2', amount: 250 }, { number: 'INV-100', amount: 1000 } ] invoices.sort(natural(i => i.number)) // => [INV-2, INV-10, INV-100] ``` ### Real-World: File Browser ```typescript interface FileEntry { name: string type: 'file' | 'directory' size: number } const entries: FileEntry[] = [ { name: 'backup-10.zip', type: 'file', size: 1024 }, { name: 'backup-2.zip', type: 'file', size: 512 }, { name: 'backup-1.zip', type: 'file', size: 256 }, { name: 'backup-20.zip', type: 'file', size: 2048 } ] // Natural sort by filename entries.sort(natural(e => e.name)) // => [backup-1.zip, backup-2.zip, backup-10.zip, backup-20.zip] ``` --- ## byKey() Convenience function for sorting objects directly by a property key. ### Signature ```typescript title="Signature" function byKey(key: K): Comparator ``` ### When to Use - Quick sorting by a single object property - When you don't need to transform the value - Cleaner syntax for simple property access ### Comparison with `ascending()` ```typescript const products = [{ name: 'Monitor', price: 300 }, { name: 'Mouse', price: 25 }] // These are equivalent: products.sort(ascending(p => p.price)) products.sort(byKey('price')) // byKey() is shorter for simple cases // ascending() is more flexible (can transform values) ``` ### Examples #### Sort by Number Property ```typescript import { byKey } from '@ontologyio/receta/compare' interface Product { id: string name: string price: number } const products: Product[] = [ { id: 'c', name: 'Monitor', price: 300 }, { id: 'a', name: 'Mouse', price: 25 }, { id: 'b', name: 'Keyboard', price: 80 } ] // Sort by price products.sort(byKey('price')) // => [Mouse (25), Keyboard (80), Monitor (300)] ``` #### Sort by String Property ```typescript const products = [ { id: 'c', name: 'Monitor' }, { id: 'a', name: 'Mouse' }, { id: 'b', name: 'Keyboard' } ] // Sort by name (alphabetically) products.sort(byKey('name')) // => [Keyboard, Monitor, Mouse] // Sort by id products.sort(byKey('id')) // => [a, b, c] ``` #### Sort by Date Property ```typescript interface Event { name: string date: Date location: string } const events: Event[] = [ { name: 'Conference', date: new Date('2024-03-15'), location: 'NYC' }, { name: 'Workshop', date: new Date('2024-02-10'), location: 'SF' }, { name: 'Meetup', date: new Date('2024-04-05'), location: 'Austin' } ] // Sort by date (oldest first) events.sort(byKey('date')) // => [Workshop (Feb 10), Conference (Mar 15), Meetup (Apr 5)] ``` ### Real-World: Data Grid ```typescript interface Employee { id: number name: string department: string salary: number hireDate: Date } const employees: Employee[] = await fetchEmployees() // User clicks column header to sort function sortByColumn(column: keyof Employee) { employees.sort(byKey(column)) } sortByColumn('salary') // Sort by salary sortByColumn('hireDate') // Sort by hire date sortByColumn('name') // Sort by name ``` --- ## Comparison Table | Comparator | Use For | Order | Example | |------------|---------|-------|---------| | `ascending(fn)` | Numbers, dates, computed values | Low β†’ High | Prices, ages, dates | | `descending(fn)` | Reverse ordering | High β†’ Low | Scores, recent dates | | `natural(fn)` | Strings with numbers | Numeric-aware | Files, versions | | `byKey(key)` | Simple property access | Low β†’ High | Quick object sorting | ## Common Patterns ### Sort by Computed Value ```typescript interface Task { priority: number urgency: number } // Sort by combined score tasks.sort(ascending(t => t.priority * t.urgency)) ``` ### Sort by String Length ```typescript const words = ['elephant', 'cat', 'dog', 'butterfly'] words.sort(ascending(w => w.length)) // => ['cat', 'dog', 'elephant', 'butterfly'] ``` ### Sort by Nested Property ```typescript interface User { profile: { age: number } } users.sort(ascending(u => u.profile.age)) ``` ### Toggle Sort Direction ```typescript function getSortComparator(direction: 'asc' | 'desc') { return direction === 'asc' ? ascending((x: Product) => x.price) : descending((x: Product) => x.price) } ``` ## Next Steps - **[Combinators](./combinators.mdx)** - Combine comparators for multi-level sorting - **[Type-Specific](./type-specific.mdx)** - Specialized comparators for dates, numbers, strings - **[Patterns](./patterns.mdx)** - Real-world recipes and solutions --- # Combinators Combinators modify and combine comparators to create complex sorting logic from simple building blocks. ## Overview | Function | Purpose | Example Use Case | |----------|---------|------------------| | `compose(...comparators)` | Multi-level sorting | Sort by status, then priority, then date | | `reverse(comparator)` | Flip sort order | Convert ascending to descending | | `nullsFirst(comparator)` | Null items at start | Empty slots first | | `nullsLast(comparator)` | Null items at end | Empty slots last | | `withTiebreaker(primary, tiebreaker)` | Explicit two-level sort | Score, then time | ## compose() Combines multiple comparators into a single multi-level sort with priority ordering. ### Signature ```typescript title="Signature" function compose(...comparators: Comparator[]): Comparator ``` ### How It Works Applies comparators in order. If the first returns 0 (equal), tries the second, and so on: ```typescript compose( comparatorA, // Try A first comparatorB, // If A says equal, try B comparatorC // If B also says equal, try C ) ``` ### When to Use - Sorting by multiple fields with different priorities - "Sort by X, then by Y, then by Z" requirements - Data tables with multi-column sorting - Complex business logic sorting ### Examples #### GitHub Issues: Status β†’ Priority β†’ Date ```typescript import { compose, ascending, byDate } from '@ontologyio/receta/compare' interface Issue { status: 'open' | 'closed' priority: 'low' | 'medium' | 'high' createdAt: Date } const statusOrder = { open: 0, closed: 1 } const priorityOrder = { high: 0, medium: 1, low: 2 } issues.sort( compose( ascending(i => statusOrder[i.status]), // 1. Open first ascending(i => priorityOrder[i.priority]), // 2. Then high priority byDate(i => i.createdAt) // 3. Then oldest ) ) // Result: Open high-priority issues (oldest first), then open medium, then open low, then closed ``` #### Employee List: Department β†’ Salary β†’ Name ```typescript interface Employee { name: string department: string salary: number } employees.sort( compose( ascending(e => e.department), // Group by department descending(e => e.salary), // Within department, highest paid first ascending(e => e.name) // Break ties by name ) ) ``` #### E-commerce: Category β†’ Price β†’ Rating ```typescript interface Product { category: string price: number rating: number } products.sort( compose( ascending(p => p.category), // Group by category ascending(p => p.price), // Cheapest first within category descending(p => p.rating) // Highest rated breaks ties ) ) ``` #### File Browser: Type β†’ Name ```typescript interface FileEntry { name: string type: 'directory' | 'file' } files.sort( compose( ascending(f => f.type === 'directory' ? 0 : 1), // Directories first natural(f => f.name) // Then natural sort by name ) ) // => [docs/, src/, package.json, README.md] ``` ### Real-World: Stripe Transactions ```typescript interface Transaction { status: 'succeeded' | 'pending' | 'failed' amount: number createdAt: Date } const statusPriority = { failed: 0, pending: 1, succeeded: 2 } transactions.sort( compose( ascending(t => statusPriority[t.status]), // Failed first (need attention) descending(t => t.amount), // Largest amounts first descending(t => t.createdAt) // Most recent first ) ) ``` --- ## reverse() Reverses the order of any comparator. ### Signature ```typescript title="Signature" function reverse(comparator: Comparator): Comparator ``` ### When to Use - Toggle between ascending/descending based on user input - Invert an existing comparator without rewriting logic - "Reverse alphabetical" or "newest first" sorting ### Examples #### Toggle Sort Direction ```typescript import { reverse, ascending } from '@ontologyio/receta/compare' function getSortComparator(direction: 'asc' | 'desc') { const base = ascending((p: Product) => p.price) return direction === 'desc' ? reverse(base) : base } // User clicks "Price ↑" products.sort(getSortComparator('asc')) // User clicks "Price ↓" products.sort(getSortComparator('desc')) ``` #### Newest First ```typescript // Instead of writing descending(), you can reverse ascending() const oldestFirst = byDate((e: CalendarEvent) => e.date) const newestFirst = reverse(oldestFirst) events.sort(newestFirst) ``` #### Reverse Alphabetical ```typescript const aToZ = ascending((u: User) => u.name) const zToA = reverse(aToZ) users.sort(zToA) // => [Zara, Yuki, Xavier, ...] ``` ### Note on `reverse()` vs `descending()` ```typescript // These are equivalent: reverse(ascending((x: Product) => x.price)) descending((x: Product) => x.price) // Use descending() for clarity when building from scratch // Use reverse() when you already have a comparator to invert ``` --- ## nullsFirst() Wraps a comparator to place null items at the beginning. It looks at the items being compared, not at a field inside them. To order objects by a nullable field, see [Sorting by a Nullable Field](#sorting-by-a-nullable-field). ### Signature ```typescript title="Signature" function nullsFirst( comparator: Comparator> ): Comparator> ``` ### How It Works Checks for null/undefined before delegating to the wrapped comparator: ```text // Pseudocode: if (a == null && b == null) return 0 // Both null = equal if (a == null) return -1 // a is null = a comes first if (b == null) return 1 // b is null = b comes first return innerComparator(a, b) // Neither null = use inner comparator ``` The wrapped comparator's type still includes null, so its extractor needs a fallback, even though a null item never reaches it. `Array.prototype.sort` and `R.sort` move `undefined` to the end without calling the comparator, so this only reorders `null`. ### When to Use - Lists that hold `null` placeholders, such as empty slots or rows still loading - Missing entries should appear prominently ### Examples #### Empty Slots First ```typescript import { nullsFirst, ascending } from '@ontologyio/receta/compare' const scores: (number | null)[] = [30, null, 10, null, 20] scores.sort(nullsFirst(ascending(score => score ?? 0))) // => [null, null, 10, 20, 30] ``` #### Objects That May Be Missing ```typescript interface Seat { label: string } const seats: (Seat | null)[] = [{ label: 'B2' }, null, { label: 'A1' }] seats.sort(nullsFirst(ascending(seat => seat?.label ?? ''))) // => [null, { label: 'A1' }, { label: 'B2' }] ``` ### Sorting by a Nullable Field When the items are objects and a field inside them can be null, `nullsFirst` has nothing to do: every item is non-null. Give the extractor a fallback that sorts where you want the nulls to land instead. #### Unassigned Tasks First ```typescript import { ascending } from '@ontologyio/receta/compare' interface Task { title: string assignee: string | null } const tasks: Task[] = [ { title: 'Fix bug', assignee: 'alice' }, { title: 'Review PR', assignee: null }, { title: 'Write docs', assignee: 'bob' }, { title: 'Deploy', assignee: null } ] // An empty string sorts before every name tasks.sort(ascending(t => t.assignee ?? '')) // => [Review PR (null), Deploy (null), Fix bug (alice), Write docs (bob)] ``` #### Missing Data First ```typescript interface User { name: string lastLogin: Date | null } // Show users who never logged in first users.sort(ascending(u => u.lastLogin ?? new Date(0))) ``` #### Optional Deadlines ```typescript interface Project { name: string deadline: Date | undefined } // Projects without deadlines shown first (need attention) projects.sort(ascending(p => p.deadline ?? new Date(0))) ``` --- ## nullsLast() Wraps a comparator to place null items at the end. Like `nullsFirst`, it looks at the items being compared, not at a field inside them. ### Signature ```typescript title="Signature" function nullsLast( comparator: Comparator> ): Comparator> ``` ### When to Use - Lists that hold `null` placeholders you want out of the way - Focus on items with data ### Examples #### Empty Slots Last ```typescript import { nullsLast, ascending } from '@ontologyio/receta/compare' const scores: (number | null)[] = [30, null, 10, null, 20] scores.sort(nullsLast(ascending(score => score ?? 0))) // => [10, 20, 30, null, null] ``` ### Sorting by a Nullable Field Last For a nullable field, use a fallback again. With `descending`, the lowest possible value lands at the end. #### Active Users First ```typescript import { descending } from '@ontologyio/receta/compare' interface User { name: string lastActivity: Date | null } // Active users first, inactive (null) at end users.sort(descending(u => u.lastActivity ?? new Date(0))) // => [Most recent activity ... least recent ... null, null] ``` #### Products with Discounts ```typescript interface Product { name: string discount: number | null } // Products with discounts first, no discount at end products.sort(descending(p => p.discount ?? 0)) ``` #### Verified Accounts ```typescript interface Account { email: string verifiedAt: Date | null } // Verified accounts first, unverified at end accounts.sort(descending(a => a.verifiedAt ?? new Date(0))) ``` ### Real-World: Linear Issues When the field only decides a group, `byBoolean` says so directly, because false sorts before true: ```typescript interface LinearIssue { title: string assignee: { name: string } | null dueDate: Date | null priority: number } // Assigned issues first, then by priority issues.sort( compose( byBoolean(i => i.assignee === null), descending(i => i.priority) ) ) ``` --- ## withTiebreaker() Combines a primary comparator with a tiebreaker for when values are equal. More explicit than `compose()` for two-level sorting. ### Signature ```typescript title="Signature" function withTiebreaker( primary: Comparator, tiebreaker: Comparator ): Comparator ``` ### When to Use - Explicit two-level sorting (more readable than `compose()` for 2 levels) - Tiebreaking in games/competitions - Secondary sort criteria clearly defined ### Examples #### Leaderboard: Score β†’ Time ```typescript import { withTiebreaker, descending, ascending } from '@ontologyio/receta/compare' interface Player { name: string score: number completionTime: number } // Highest score wins, fastest time breaks ties players.sort( withTiebreaker( descending(p => p.score), ascending(p => p.completionTime) ) ) ``` #### Sales Report: Revenue β†’ Region ```typescript interface SalesRegion { region: string revenue: number } // Highest revenue first, alphabetical breaks ties regions.sort( withTiebreaker( descending(r => r.revenue), ascending(r => r.region) ) ) ``` #### Job Applications: Experience β†’ Education ```typescript interface Applicant { name: string yearsExperience: number educationLevel: number } // Most experience first, highest education breaks ties applicants.sort( withTiebreaker( descending(a => a.yearsExperience), descending(a => a.educationLevel) ) ) ``` ### `withTiebreaker()` vs `compose()` ```typescript // These are equivalent: withTiebreaker(comparatorA, comparatorB) compose(comparatorA, comparatorB) // withTiebreaker is more explicit for 2-level sorting // compose is better for 3+ levels ``` --- ## Combining Combinators You can nest combinators for complex logic: ### Example: Multi-Level with Null Handling ```typescript interface Task { status: 'open' | 'closed' assignee: string | null priority: number createdAt: Date } tasks.sort( compose( ascending(t => t.status === 'open' ? 0 : 1), // Open first byBoolean(t => t.assignee !== null), // Unassigned first within status descending(t => t.priority), // High priority first byDate(t => t.createdAt) // Oldest first ) ) // Open unassigned high-priority old tasks first! ``` ### Example: Reversing a Composed Comparator ```typescript const normalOrder = compose( ascending((p: Product) => p.category), ascending((p: Product) => p.price) ) const reverseOrder = reverse(normalOrder) // Completely reverses the entire sort products.sort(reverseOrder) ``` ### Example: Nullable Values in Multi-Level Sort ```typescript interface Employee { department: string manager: string | null salary: number } employees.sort( compose( ascending(e => e.department), byBoolean(e => e.manager === null), // No manager goes last descending(e => e.salary) ) ) ``` --- ## Comparison Table | Combinator | Effect | Use When | |------------|--------|----------| | `compose(a, b, c)` | Multi-level sort | 3+ sort criteria | | `withTiebreaker(a, b)` | Primary + tiebreak | Exactly 2 levels (more explicit) | | `reverse(comparator)` | Flip order | Toggle asc/desc | | `nullsFirst(comparator)` | Null items at start | Missing entries need attention | | `nullsLast(comparator)` | Null items at end | Focus on entries with data | ## Common Patterns ### Pattern: User-Controlled Multi-Column Sort ```typescript interface SortConfig { column: string direction: 'asc' | 'desc' } function buildComparator(configs: SortConfig[]): Comparator { const comparators = configs.map(cfg => { const base = byKey(cfg.column as keyof Item) return cfg.direction === 'desc' ? reverse(base) : base }) return compose(...comparators) } // User clicks: "Department ↑, Salary ↓, Name ↑" const comparator = buildComparator([ { column: 'department', direction: 'asc' }, { column: 'salary', direction: 'desc' }, { column: 'name', direction: 'asc' } ]) ``` ### Pattern: Priority Queue ```typescript interface QueueItem { priority: number enqueuedAt: Date } // Higher priority first, FIFO breaks ties queue.sort( withTiebreaker( descending(item => item.priority), ascending(item => item.enqueuedAt) ) ) ``` ### Pattern: Grouped Sorting ```typescript interface Message { isStarred: boolean isRead: boolean receivedAt: Date } // Starred first, then unread, then by date messages.sort( compose( reverse(byBoolean(m => m.isStarred)), byBoolean(m => m.isRead), descending(m => m.receivedAt) ) ) ``` ## Next Steps - **[Type-Specific Comparators](./type-specific.mdx)** - Specialized comparators for dates, numbers, strings, booleans - **[Patterns & Recipes](./patterns.mdx)** - Real-world solutions - **[API Reference](../reference/compare.mdx)** - Complete function catalog --- # Migration Guide Step-by-step guide to refactoring existing sort code to use Compare module. ## Why Migrate? **Before (Array.sort callback):** ```typescript users.sort((a, b) => { if (a.department < b.department) return -1 if (a.department > b.department) return 1 if (a.salary > b.salary) return -1 if (a.salary < b.salary) return 1 return a.name.localeCompare(b.name) }) ``` **After (Compare module):** ```typescript import { compose, ascending, descending, caseInsensitive } from '@ontologyio/receta/compare' users.sort( compose( ascending(u => u.department), descending(u => u.salary), caseInsensitive(u => u.name) ) ) ``` βœ… More readable βœ… Type-safe βœ… Reusable βœ… Testable βœ… Composable ## Step 1: Identify Sortable Fields Find all `Array.sort()` calls in your codebase: ```bash # Search for sort callbacks grep -r "\.sort\((a, b)" src/ grep -r "\.sort\(function" src/ ``` Document each sort's logic: - What fields are being compared? - What's the sort order (asc/desc)? - Are nulls handled? - Is it multi-level? ## Step 2: Replace Simple Sorts ### Pattern 1: Single Field Ascending ```typescript // Before numbers.sort((a, b) => a - b) // After import { ascending } from '@ontologyio/receta/compare' numbers.sort(ascending(x => x)) ``` ### Pattern 2: Single Field Descending ```typescript // Before scores.sort((a, b) => b - a) // After import { descending } from '@ontologyio/receta/compare' scores.sort(descending(x => x)) ``` ### Pattern 3: Object Property ```typescript const users = [{ name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }] // Before users.sort((a, b) => a.age - b.age) // After import { ascending } from '@ontologyio/receta/compare' users.sort(ascending(u => u.age)) // Or use byKey for direct property access import { byKey } from '@ontologyio/receta/compare' users.sort(byKey('age')) ``` ### Pattern 4: String Comparison ```typescript // Before names.sort((a, b) => a.localeCompare(b)) // After import { ascending } from '@ontologyio/receta/compare' names.sort(ascending(x => x)) ``` ### Pattern 5: Date Comparison ```typescript // Before events.sort((a, b) => a.date.getTime() - b.date.getTime()) // After import { byDate } from '@ontologyio/receta/compare' events.sort(byDate(e => e.date)) ``` ## Step 3: Replace Multi-Level Sorts ### Pattern: Nested If Statements ```typescript // Before items.sort((a, b) => { if (a.priority !== b.priority) { return b.priority - a.priority // Higher priority first } if (a.status !== b.status) { return a.status.localeCompare(b.status) } return a.createdAt.getTime() - b.createdAt.getTime() }) // After import { compose, descending, ascending, byDate } from '@ontologyio/receta/compare' items.sort( compose( descending(i => i.priority), ascending(i => i.status), byDate(i => i.createdAt) ) ) ``` ### Pattern: Ternary Chain ```typescript // Before products.sort((a, b) => a.category !== b.category ? a.category.localeCompare(b.category) : b.rating !== a.rating ? b.rating - a.rating : a.price - b.price ) // After import { compose, ascending, descending } from '@ontologyio/receta/compare' products.sort( compose( ascending(p => p.category), descending(p => p.rating), ascending(p => p.price) ) ) ``` ## Step 4: Handle Null Values ### Pattern: Nullish Coalescing ```typescript // Before users.sort((a, b) => { const aVal = a.lastLogin ?? new Date(0) const bVal = b.lastLogin ?? new Date(0) return aVal.getTime() - bVal.getTime() }) // After import { ascending } from '@ontologyio/receta/compare' users.sort(ascending(u => u.lastLogin ?? new Date(0))) ``` ### Pattern: Explicit Null Checks ```typescript // Before items.sort((a, b) => { if (a.value === null && b.value === null) return 0 if (a.value === null) return -1 if (b.value === null) return 1 return a.value - b.value }) // After import { ascending, byBoolean, compose } from '@ontologyio/receta/compare' items.sort( compose( byBoolean(i => i.value !== null), // false sorts first, so nulls lead ascending(i => i.value ?? 0) ) ) ``` ## Step 5: Handle String Case ### Pattern: toLowerCase() Comparison ```typescript // Before files.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) // After import { caseInsensitive } from '@ontologyio/receta/compare' files.sort(caseInsensitive(f => f.name)) ``` ### Pattern: Case-Sensitive with Locale ```typescript // Before const collator = new Intl.Collator('de-DE') names.sort((a, b) => collator.compare(a, b)) // After import { localeCompare } from '@ontologyio/receta/compare' names.sort(localeCompare(x => x, 'de-DE')) ``` ## Step 6: Handle Natural Sorting ### Pattern: Numeric String Comparison ```typescript // Before (wrong - lexicographic) files.sort((a, b) => a.name.localeCompare(b.name)) // => ['file1.txt', 'file10.txt', 'file2.txt'] ❌ // After import { natural } from '@ontologyio/receta/compare' files.sort(natural(f => f.name)) // => ['file1.txt', 'file2.txt', 'file10.txt'] βœ… ``` ## Step 7: Extract Reusable Comparators Before: ```typescript // Repeated in multiple places function sortUsersByName(users: User[]) { return users.sort((a, b) => a.lastName.localeCompare(b.lastName)) } function sortEmployeesByName(employees: Employee[]) { return employees.sort((a, b) => a.lastName.localeCompare(b.lastName)) } ``` After: ```typescript import { ascending } from '@ontologyio/receta/compare' // Reusable comparator const byLastName = ascending((person: { lastName: string }) => person.lastName) // Use everywhere function sortUsersByName(users: User[]) { return users.sort(byLastName) } function sortEmployeesByName(employees: Employee[]) { return employees.sort(byLastName) } ``` ## Step 8: Parameterize Sort Direction Before: ```typescript function sortProducts(products: Product[], direction: 'asc' | 'desc') { return products.sort((a, b) => { const diff = a.price - b.price return direction === 'asc' ? diff : -diff }) } ``` After: ```typescript import { ascending, descending } from '@ontologyio/receta/compare' function sortProducts(products: Product[], direction: 'asc' | 'desc') { const comparator = direction === 'asc' ? ascending(p => p.price) : descending(p => p.price) return products.sort(comparator) } ``` ## Migration Checklist For each `Array.sort()` in your codebase: - [ ] Identify all fields being compared - [ ] Determine sort order for each field (asc/desc) - [ ] Check for null/undefined handling - [ ] Look for string case handling - [ ] Check if natural sorting is needed - [ ] Identify if it's single or multi-level - [ ] Replace with appropriate Compare functions - [ ] Test that sort order is identical - [ ] Extract reusable comparators if repeated - [ ] Update tests to use new comparators ## Common Pitfalls ### Pitfall 1: Forgetting to Return from Arrow Functions ```ts nocheck // ❌ Wrong const comparator = ascending(x => { x.value }) // βœ… Correct const comparator = ascending(x => x.value) ``` ### Pitfall 2: Mutating During Comparison ```typescript // ❌ Don't mutate in comparator items.sort(ascending((x: Item) => { x.viewed = true // Side effect! return x.priority })) // βœ… Pure extraction items.sort(ascending((x: Item) => x.priority)) ``` ### Pitfall 3: Wrong compose() Order ```typescript // ❌ Wrong order (priority before status) compose( ascending((x: Item) => x.priority), ascending((x: Item) => x.status) ) // βœ… Correct (status before priority) compose( ascending((x: Item) => x.status), ascending((x: Item) => x.priority) ) // compose() applies in order: first has highest priority ``` ### Pitfall 4: Comparing Different Types ```typescript // ❌ Mixing types ascending((x: Item) => x.date ?? 'never') // Date | string // βœ… Consistent types ascending((x: Item) => x.date ?? new Date(0)) // Always Date ``` ## Testing Your Migration ### Unit Test Template ```typescript import { describe, it, expect } from 'bun:test' import { yourComparator } from './comparators' describe('Migration: Product sorting', () => { it('maintains same order as original implementation', () => { const products = [{ price: 20 }, { price: 10 }] // Original implementation const originalSort = [...products].sort((a, b) => a.price - b.price) // New implementation const newSort = [...products].sort(yourComparator) expect(newSort).toEqual(originalSort) }) it('handles edge cases', () => { const edgeCases = [{ price: 0 }, { price: 10 }, { price: 10 }] const sorted = edgeCases.sort(yourComparator) expect(sorted).toBeDefined() }) }) ``` ## Incremental Migration Strategy 1. **Start with leaf nodes** - Migrate simplest sorts first 2. **One file at a time** - Don't refactor everything at once 3. **Test thoroughly** - Ensure sort order is identical 4. **Extract patterns** - Create reusable comparators 5. **Document decisions** - Note any behavioral changes ## Next Steps - **[Patterns & Recipes](./patterns.mdx)** - Copy-paste solutions - **[API Reference](../reference/compare.mdx)** - Complete function catalog - **[Basic Comparators](./basic-comparators.mdx)** - Foundation functions --- # Common Patterns & Recipes Production-ready sorting patterns for real-world applications. ## GitHub Issues Management ```typescript import { compose, ascending, descending, byBoolean, byDate } from '@ontologyio/receta/compare' interface GitHubIssue { number: number title: string status: 'open' | 'closed' priority: 'low' | 'medium' | 'high' assignee: string | null labels: string[] createdAt: Date updatedAt: Date comments: number } // Pattern 1: Default issue list (status β†’ priority β†’ age) const statusOrder = { open: 0, closed: 1 } const priorityOrder = { high: 0, medium: 1, low: 2 } export const sortIssuesDefault = compose( ascending((i: GitHubIssue) => statusOrder[i.status]), ascending((i: GitHubIssue) => priorityOrder[i.priority]), byDate((i: GitHubIssue) => i.createdAt) ) // Pattern 2: Triage view (unassigned β†’ high priority β†’ oldest) export const sortIssuesTriage = compose( byBoolean((i: GitHubIssue) => i.assignee !== null), ascending((i: GitHubIssue) => priorityOrder[i.priority]), byDate((i: GitHubIssue) => i.createdAt) ) // Pattern 3: Activity view (most discussed β†’ recently updated) export const sortIssuesActivity = compose( descending((i: GitHubIssue) => i.comments), descending((i: GitHubIssue) => i.updatedAt) ) // Pattern 4: User-specific (assigned to me β†’ priority β†’ deadline) export function sortIssuesForUser(username: string) { return compose( ascending((i: GitHubIssue) => i.assignee === username ? 0 : 1), ascending((i: GitHubIssue) => priorityOrder[i.priority]), byDate((i: GitHubIssue) => i.createdAt) ) } ``` ## Stripe Payment Dashboard ```typescript interface StripePayment { id: string amount: number currency: string status: 'succeeded' | 'pending' | 'failed' | 'refunded' customer: string created: Date description: string } // Pattern 1: Alert view (failed first β†’ largest β†’ most recent) const statusPriority = { failed: 0, pending: 1, refunded: 2, succeeded: 3 } export const sortPaymentsAlerts = compose( ascending((p: StripePayment) => statusPriority[p.status]), descending((p: StripePayment) => p.amount), descending((p: StripePayment) => p.created) ) // Pattern 2: Financial view (by currency β†’ amount β†’ date) export const sortPaymentsFinancial = compose( ascending((p: StripePayment) => p.currency), descending((p: StripePayment) => p.amount), descending((p: StripePayment) => p.created) ) // Pattern 3: Customer view (by customer β†’ most recent) export const sortPaymentsByCustomer = compose( ascending((p: StripePayment) => p.customer), descending((p: StripePayment) => p.created) ) ``` ## Data Table with Column Sorting ```typescript interface Employee { id: number name: string department: string salary: number hireDate: Date isActive: boolean } interface SortConfig { column: keyof Employee direction: 'asc' | 'desc' } export function buildTableComparator(configs: SortConfig[]): Comparator { const comparators = configs.map(({ column, direction }) => { let base: Comparator switch (column) { case 'hireDate': base = byDate(e => e.hireDate) break case 'salary': base = byNumber(e => e.salary) break case 'isActive': base = byBoolean(e => e.isActive) break default: base = caseInsensitive(e => String(e[column])) } return direction === 'desc' ? reverse(base) : base }) return compose(...comparators) } // Usage: User clicks "Department ↑, Salary ↓, Name ↑" const comparator = buildTableComparator([ { column: 'department', direction: 'asc' }, { column: 'salary', direction: 'desc' }, { column: 'name', direction: 'asc' } ]) employees.sort(comparator) ``` ## File Browser ```typescript interface FileEntry { name: string type: 'file' | 'directory' size: number modifiedAt: Date extension: string } // Pattern 1: Standard view (directories β†’ natural name) export const sortFilesStandard = compose( ascending((f: FileEntry) => f.type === 'directory' ? 0 : 1), natural((f: FileEntry) => f.name) ) // Pattern 2: By size (largest first, directories last) export const sortFilesBySize = compose( ascending((f: FileEntry) => f.type === 'directory' ? 1 : 0), descending((f: FileEntry) => f.size) ) // Pattern 3: By modified date (most recent first) export const sortFilesByDate = descending((f: FileEntry) => f.modifiedAt) // Pattern 4: By type (group extensions) export const sortFilesByType = compose( ascending((f: FileEntry) => f.type === 'directory' ? 0 : 1), ascending((f: FileEntry) => f.extension), natural((f: FileEntry) => f.name) ) ``` ## E-commerce Product Listings ```typescript interface Product { id: string name: string category: string price: number rating: number reviewCount: number inStock: boolean releaseDate: Date } // Pattern 1: Best sellers (in stock β†’ highest rated β†’ most reviews) export const sortProductsBestSellers = compose( reverse(byBoolean((p: Product) => p.inStock)), descending((p: Product) => p.rating), descending((p: Product) => p.reviewCount) ) // Pattern 2: New arrivals (most recent β†’ highest rated) export const sortProductsNewArrivals = compose( descending((p: Product) => p.releaseDate), descending((p: Product) => p.rating) ) // Pattern 3: Price low to high export const sortProductsPriceLow = compose( reverse(byBoolean((p: Product) => p.inStock)), ascending((p: Product) => p.price) ) // Pattern 4: By category and rating export const sortProductsCategoryRating = compose( ascending((p: Product) => p.category), descending((p: Product) => p.rating), descending((p: Product) => p.reviewCount) ) ``` ## Task Management ```typescript interface Task { id: string title: string status: 'todo' | 'in_progress' | 'done' priority: number // 1-5, 5 is highest dueDate: Date | null assignee: string | null createdAt: Date } // Pattern 1: My tasks (in progress β†’ high priority β†’ due soon) const statusOrder = { in_progress: 0, todo: 1, done: 2 } export const sortTasksMine = compose( ascending((t: Task) => statusOrder[t.status]), descending((t: Task) => t.priority), ascending((t: Task) => t.dueDate ?? new Date(9999, 0)), ascending((t: Task) => t.createdAt) ) // Pattern 2: Team view (unassigned β†’ priority β†’ due date) export const sortTasksTeam = compose( byBoolean((t: Task) => t.assignee !== null), descending((t: Task) => t.priority), ascending((t: Task) => t.dueDate ?? new Date(9999, 0)) ) // Pattern 3: Overdue tasks (past due β†’ highest priority) export function sortTasksOverdue(now: Date) { return compose( ascending((t: Task) => { if (!t.dueDate) return 1 return t.dueDate < now ? 0 : 1 }), ascending((t: Task) => t.dueDate ?? new Date(9999, 0)), descending((t: Task) => t.priority) ) } ``` ## Search Results ```typescript interface SearchResult { id: string title: string description: string relevanceScore: number category: string publishedAt: Date viewCount: number } // Pattern 1: By relevance (highest β†’ most viewed) export const sortSearchByRelevance = compose( descending((r: SearchResult) => r.relevanceScore), descending((r: SearchResult) => r.viewCount) ) // Pattern 2: Most recent (newest β†’ relevant) export const sortSearchByDate = compose( descending((r: SearchResult) => r.publishedAt), descending((r: SearchResult) => r.relevanceScore) ) // Pattern 3: By category and relevance export const sortSearchByCategory = compose( ascending((r: SearchResult) => r.category), descending((r: SearchResult) => r.relevanceScore) ) // Pattern 4: Popularity (most viewed β†’ relevant β†’ recent) export const sortSearchByPopularity = compose( descending((r: SearchResult) => r.viewCount), descending((r: SearchResult) => r.relevanceScore), descending((r: SearchResult) => r.publishedAt) ) ``` ## Analytics Dashboard ```typescript interface Metric { name: string current: number previous: number change: number changePercent: number trend: 'up' | 'down' | 'stable' } // Pattern 1: Biggest movers (largest % change) export const sortMetricsByChange = descending( (m: Metric) => Math.abs(m.changePercent) ) // Pattern 2: Alerts (negative trends β†’ largest drops) export const sortMetricsAlerts = compose( ascending((m: Metric) => m.trend === 'down' ? 0 : 1), ascending((m: Metric) => m.change) ) // Pattern 3: Performance (highest current β†’ growing) export const sortMetricsPerformance = compose( descending((m: Metric) => m.current), descending((m: Metric) => m.changePercent) ) ``` ## Event Calendar ```typescript interface CalendarEvent { id: string title: string startDate: Date endDate: Date isAllDay: boolean category: string attendees: number } // Pattern 1: Chronological (start date β†’ duration) export const sortEventsChronological = compose( byDate((e: CalendarEvent) => e.startDate), ascending((e: CalendarEvent) => e.endDate.getTime() - e.startDate.getTime()) ) // Pattern 2: Upcoming (all-day last β†’ start time) export const sortEventsUpcoming = compose( ascending((e: CalendarEvent) => e.isAllDay ? 1 : 0), byDate((e: CalendarEvent) => e.startDate) ) // Pattern 3: By category and time export const sortEventsByCategory = compose( ascending((e: CalendarEvent) => e.category), byDate((e: CalendarEvent) => e.startDate) ) ``` ## Leaderboard / Rankings ```typescript interface LeaderboardEntry { rank: number player: string score: number level: number completionTime: number achievements: number } // Pattern 1: By score (highest β†’ fastest completion) export const sortLeaderboardByScore = compose( descending((e: LeaderboardEntry) => e.score), ascending((e: LeaderboardEntry) => e.completionTime) ) // Pattern 2: By level (highest β†’ most achievements) export const sortLeaderboardByLevel = compose( descending((e: LeaderboardEntry) => e.level), descending((e: LeaderboardEntry) => e.achievements), ascending((e: LeaderboardEntry) => e.completionTime) ) // Pattern 3: Speed run (fastest completion β†’ highest score) export const sortLeaderboardSpeedrun = compose( ascending((e: LeaderboardEntry) => e.completionTime), descending((e: LeaderboardEntry) => e.score) ) ``` ## API Response Pagination ```typescript interface PaginatedRequest { sortBy?: string sortOrder?: 'asc' | 'desc' page: number pageSize: number } function buildAPIComparator>( request: PaginatedRequest ): Comparator | null { if (!request.sortBy) return null const extractor = (item: T) => item[request.sortBy!] const base = ascending(extractor) return request.sortOrder === 'desc' ? reverse(base) : base } // Usage in API handler async function handleListRequest(req: PaginatedRequest) { let items = await fetchItems() const comparator = buildAPIComparator(req) if (comparator) { items = items.sort(comparator) } return paginate(items, { page: req.page, pageSize: req.pageSize }) } ``` ## Next Steps - **[Migration Guide](./migration.mdx)** - Converting existing sort code - **[API Reference](../reference/compare.mdx)** - Complete function catalog --- # Type-Specific Comparators Type-specific comparators provide specialized sorting for dates, numbers, strings, and booleans with proper handling of edge cases. ## Overview | Function | Type | Handles | Example | |----------|------|---------|---------| | `byDate(fn)` | Date | Chronological ordering | Event timelines, logs | | `byNumber(fn)` | number | NaN, Infinity | Prices, scores, metrics | | `byString(fn, options)` | string | Case, locale | Names, titles | | `byBoolean(fn)` | boolean | false < true | Flags, toggles | ## byDate() Sorts Date objects chronologically (earliest to latest). ### Signature ```typescript title="Signature" function byDate(fn: (value: T) => Date): Comparator ``` ### Why Use byDate() vs ascending()? ```typescript // βœ… byDate - explicitly handles dates events.sort(byDate(e => e.createdAt)) // ⚠️ ascending - works but less clear events.sort(ascending(e => e.createdAt)) // Both work, but byDate() signals intent and may optimize internally ``` ### Examples #### Event Timeline ```typescript import { byDate } from '@ontologyio/receta/compare' interface Event { name: string occurredAt: Date } const events: Event[] = [ { name: 'Launch', occurredAt: new Date('2024-03-01') }, { name: 'Meeting', occurredAt: new Date('2024-02-15') }, { name: 'Review', occurredAt: new Date('2024-02-20') } ] // Chronological (oldest first) events.sort(byDate(e => e.occurredAt)) // => [Meeting (Feb 15), Review (Feb 20), Launch (Mar 1)] ``` #### Most Recent First ```typescript import { reverse, byDate } from '@ontologyio/receta/compare' // Newest first events.sort(reverse(byDate(e => e.occurredAt))) // => [Launch (Mar 1), Review (Feb 20), Meeting (Feb 15)] ``` #### Real-World: GitHub Commits ```typescript interface Commit { sha: string message: string authoredAt: Date committedAt: Date } // Sort by commit date (most recent first) commits.sort(reverse(byDate(c => c.committedAt))) // Sort by author date commits.sort(byDate(c => c.authoredAt)) ``` #### Real-World: Stripe Transactions ```typescript interface Transaction { id: string amount: number created: Date } // Most recent transactions first transactions.sort(reverse(byDate(t => t.created))) ``` #### Log Files ```typescript interface LogEntry { level: string message: string timestamp: Date } // Chronological log order logs.sort(byDate(l => l.timestamp)) ``` --- ## byNumber() Sorts numbers with proper handling of NaN and Infinity. ### Signature ```typescript title="Signature" function byNumber(fn: (value: T) => number): Comparator ``` ### Special Value Handling - **NaN**: Treated as equal to each other, sorted to the end - **Infinity**: Sorted as the largest value - **-Infinity**: Sorted as the smallest value ### Examples #### Basic Number Sorting ```typescript import { byNumber } from '@ontologyio/receta/compare' interface Metric { name: string value: number } const metrics: Metric[] = [ { name: 'CPU', value: 45.2 }, { name: 'Memory', value: 78.9 }, { name: 'Disk', value: 23.1 } ] // Lowest to highest metrics.sort(byNumber(m => m.value)) // => [Disk (23.1), CPU (45.2), Memory (78.9)] ``` #### Handling NaN ```typescript const values = [ { name: 'A', score: 100 }, { name: 'B', score: NaN }, { name: 'C', score: 50 }, { name: 'D', score: NaN } ] values.sort(byNumber(v => v.score)) // => [C (50), A (100), B (NaN), D (NaN)] // NaN values grouped at end ``` #### Handling Infinity ```typescript const limits = [ { name: 'Basic', max: 100 }, { name: 'Unlimited', max: Infinity }, { name: 'Trial', max: 10 } ] limits.sort(byNumber(l => l.max)) // => [Trial (10), Basic (100), Unlimited (Infinity)] ``` #### Real-World: Analytics Dashboard ```typescript interface KPI { metric: string current: number change: number } // Sort by largest change (positive or negative) kpis.sort(descending(byNumber(k => Math.abs(k.change)))) ``` #### Real-World: Pricing Tiers ```typescript interface PricingTier { name: string pricePerMonth: number userLimit: number } // Cheapest plans first tiers.sort(byNumber(t => t.pricePerMonth)) // Plans with most users first tiers.sort(reverse(byNumber(t => t.userLimit))) ``` --- ## byString() Sorts strings with options for case sensitivity and locale awareness. ### Signature ```typescript function byString( fn: (value: T) => string, options?: StringCompareOptions ): Comparator interface StringCompareOptions { caseSensitive?: boolean // default: true locale?: string // default: undefined } ``` ### Examples #### Case-Sensitive (Default) ```typescript import { byString } from '@ontologyio/receta/compare' const users = [ { name: 'alice' }, { name: 'Bob' }, { name: 'Charlie' } ] // Default: case-sensitive (uppercase before lowercase) users.sort(byString(u => u.name)) // => [Bob, Charlie, alice] // ^uppercase first ``` #### Case-Insensitive ```typescript // Ignore case users.sort(byString(u => u.name, { caseSensitive: false })) // => [alice, Bob, Charlie] // ^alphabetical regardless of case ``` #### Locale-Aware Sorting ```typescript const cities = [ { name: 'ZΓΌrich' }, { name: 'Berlin' }, { name: 'MΓΌnchen' } ] // German locale (handles ΓΌ, ΓΆ correctly) cities.sort(byString(c => c.name, { locale: 'de-DE' })) ``` #### Real-World: User Directory ```typescript interface User { firstName: string lastName: string email: string } // Sort by last name, case-insensitive users.sort(byString(u => u.lastName, { caseSensitive: false })) // Sort by email (case-sensitive for domains) users.sort(byString(u => u.email)) ``` #### Real-World: Internationalized Product Catalog ```typescript interface Product { name: string category: string } // French locale for product names const frenchProducts = products.filter(p => p.locale === 'fr') frenchProducts.sort(byString(p => p.name, { locale: 'fr-FR' })) ``` --- ## byBoolean() Sorts boolean values (false < true). ### Signature ```typescript title="Signature" function byBoolean(fn: (value: T) => boolean): Comparator ``` ### Default Order - `false` comes before `true` (false < true) - Use `reverse()` for true-first sorting ### Examples #### Incomplete Tasks First ```typescript import { byBoolean } from '@ontologyio/receta/compare' interface Task { title: string completed: boolean } const tasks: Task[] = [ { title: 'Review PR', completed: true }, { title: 'Fix bug', completed: false }, { title: 'Write docs', completed: false } ] // Incomplete (false) first tasks.sort(byBoolean(t => t.completed)) // => [Fix bug (false), Write docs (false), Review PR (true)] ``` #### Urgent Items First ```typescript import { reverse, byBoolean } from '@ontologyio/receta/compare' interface Item { name: string urgent: boolean } // Urgent (true) first items.sort(reverse(byBoolean(i => i.urgent))) // => [urgent items... non-urgent items...] ``` #### Multi-Level: Urgent + Incomplete ```typescript import { compose, reverse, byBoolean } from '@ontologyio/receta/compare' interface Task { title: string urgent: boolean completed: boolean } // Urgent first, then incomplete first within each urgency level tasks.sort( compose( reverse(byBoolean(t => t.urgent)), byBoolean(t => t.completed) ) ) // => [Urgent incomplete, Urgent complete, Normal incomplete, Normal complete] ``` #### Real-World: Feature Flags ```typescript interface FeatureFlag { name: string enabled: boolean rolloutPercentage: number } // Enabled features first, then by rollout percentage flags.sort( compose( reverse(byBoolean(f => f.enabled)), descending(f => f.rolloutPercentage) ) ) ``` #### Real-World: Email Inbox ```typescript interface Email { subject: string isStarred: boolean isRead: boolean receivedAt: Date } // Starred first, then unread, then by date emails.sort( compose( reverse(byBoolean(e => e.isStarred)), byBoolean(e => e.isRead), reverse(byDate(e => e.receivedAt)) ) ) ``` --- ## Comparison Table | Type | Function | Default Order | Reverse For | |------|----------|---------------|-------------| | Date | `byDate(fn)` | Oldest β†’ Newest | Most recent first | | Number | `byNumber(fn)` | Low β†’ High | Highest first | | String | `byString(fn)` | A β†’ Z (case-sensitive) | Z β†’ A, case-insensitive | | Boolean | `byBoolean(fn)` | false β†’ true | true first (active/enabled) | ## When to Use Each ### Use byDate() when: - Sorting events, logs, or transactions - Chronological ordering matters - Need "most recent first" or "oldest first" ### Use byNumber() when: - Sorting prices, scores, metrics, ratings - Data may contain NaN or Infinity - Numeric comparison (not lexicographic) ### Use byString() when: - Sorting names, titles, categories - Need case-insensitive sorting - Working with internationalized content - Default `ascending()` doesn't handle case/locale correctly ### Use byBoolean() when: - Sorting by flags, toggles, status (active/inactive) - Grouping by binary states - Need explicit true/false ordering ## Common Patterns ### Pattern: Sort by Date Range ```typescript interface Event { startDate: Date endDate: Date } // Sort by start date, end date breaks ties events.sort( compose( byDate(e => e.startDate), byDate(e => e.endDate) ) ) ``` ### Pattern: Sort by Computed Number ```typescript interface Product { price: number quantity: number } // Sort by total value (price * quantity) products.sort(byNumber(p => p.price * p.quantity)) ``` ### Pattern: Multi-Language Support ```typescript function sortByName(users: User[], locale: string) { return users.sort(byString(u => u.name, { locale })) } sortByName(users, 'en-US') // English sortByName(users, 'de-DE') // German sortByName(users, 'ja-JP') // Japanese ``` ### Pattern: Status Flags ```typescript interface Account { isVerified: boolean isPremium: boolean isActive: boolean } // Premium verified active accounts first accounts.sort( compose( reverse(byBoolean(a => a.isPremium)), reverse(byBoolean(a => a.isVerified)), reverse(byBoolean(a => a.isActive)) ) ) ``` ## Next Steps - **[Advanced Comparators](./advanced.mdx)** - Case-insensitive, locale-specific sorting - **[Patterns & Recipes](./patterns.mdx)** - Real-world solutions - **[API Reference](../reference/compare.mdx)** - Complete function catalog --- # Overview > Composable function combinators and utilities for building clean, declarative TypeScript code. ## What is the Function Module? The Function module provides higher-order utilities that let you compose, transform, and control function behavior in a declarative way. Instead of writing imperative if-else chains and nested callbacks, you build pipelines using composable building blocks. ## Why Use Function Combinators? ### Problem: Imperative Code Gets Messy ```typescript // ❌ Traditional imperative approach function processUser(user: User): ProcessedUser { let result = user // Nested if-else logic if (!result.email.includes('@')) { result = { ...result, email: 'invalid@example.com' } } if (result.username.length === 0) { const emailParts = result.email.split('@') result = { ...result, username: emailParts[0] || 'anonymous' } } if (result.password.length < 8) { result = { ...result, password: result.password + '12345678'.slice(result.password.length) } } return result } ``` ### Solution: Declarative Function Composition ```typescript // βœ… Declarative approach with function combinators import { pipe } from 'remeda' import { when, unless } from '@ontologyio/receta/function' const processUser = (user: User) => pipe( user, when( (u) => !u.email.includes('@'), (u) => ({ ...u, email: 'invalid@example.com' }) ), unless( (u) => u.username.length > 0, (u) => ({ ...u, username: u.email.split('@')[0] || 'anonymous' }) ), when( (u) => u.password.length < 8, (u) => ({ ...u, password: u.password + '12345678'.slice(u.password.length) }) ) ) ``` **Benefits:** - **Readable**: Each transformation is clearly isolated - **Composable**: Easy to add/remove/reorder steps - **Testable**: Each function can be tested independently - **Type-safe**: Full TypeScript inference throughout ## Module Categories The Function module is organized into five categories: ### 1. Conditional Combinators Execute code based on predicates without if-else statements. ```typescript import { ifElse, when, unless, cond } from '@ontologyio/receta/function' // Branching const classify = ifElse( (n: number) => n >= 0, (n) => 'positive', (n) => 'negative' ) // Multi-way conditionals const httpStatus = cond([ [(s) => s >= 200 && s < 300, () => 'success'], [(s) => s >= 400 && s < 500, () => 'client error'], [(s) => s >= 500, () => 'server error'] ]) ``` **When to use:** HTTP status handling, form validation, state machines ### 2. Composition Utilities Combine multiple functions into new functions. ```typescript import { compose, converge, juxt } from '@ontologyio/receta/function' // Build complex objects from single input const buildProfile = converge( (name, username, domain) => ({ name, username, domain }), [ (user: User) => user.fullName, (user: User) => user.email.split('@')[0], (user: User) => user.email.split('@')[1] ] ) // Extract multiple metrics const stats = juxt([ (nums: number[]) => nums.length, (nums: number[]) => Math.max(...nums), (nums: number[]) => Math.min(...nums) ]) ``` **When to use:** Data transformation, analytics, deriving multiple values from single input ### 3. Partial Application Pre-fill function arguments to create specialized versions. ```typescript import { partial, partialRight, flip } from '@ontologyio/receta/function' const log = (level: string, module: string, msg: string) => `[${level}] ${module}: ${msg}` const logError = partial(log, 'ERROR') const logUserError = partial(logError, 'UserModule') logUserError('Invalid input') // => '[ERROR] UserModule: Invalid input' ``` **When to use:** Logging systems, specialized validators, configuration ### 4. Arity Control Control how many arguments a function accepts. ```typescript import { unary, binary, nAry } from '@ontologyio/receta/function' // Fix parseInt with map const digits = ['1', '2', '3'] digits.map(parseInt) // => [1, NaN, NaN] digits.map(unary(parseInt)) // => [1, 2, 3] ``` **When to use:** Array callbacks, adapting functions for different contexts ### 5. Control Flow Manage side effects, errors, and performance. ```typescript import { tap, tryCatch, memoize } from '@ontologyio/receta/function' // Debug pipelines pipe( data, transform, tap((x) => console.log('After transform:', x)), validate, tap((x) => console.log('After validate:', x)) ) // Safe parsing const parseJSON = tryCatch( (str: string) => JSON.parse(str), (error) => `Parse error: ${error}` ) ``` **When to use:** Debugging, error handling, performance optimization ## Core Principles ### 1. Data-First and Data-Last Signatures Combinators that work on a value (`when`, `unless`, `ifElse`, `cond`, `tap` and others) support both calling styles: ```typescript // Data-first (direct call) when(predicate, fn, value) // Data-last (for pipe) pipe(value, when(predicate, fn)) ``` ### 2. Composability Functions work seamlessly with Remeda's `pipe`: ```typescript import { pipe, map, filter } from 'remeda' import { when, tap, converge } from '@ontologyio/receta/function' const result = pipe( rawData, when(needsNormalization, normalizeRecord), map(transform), tap(logProgress), filter(isComplete), converge(buildSummary, extractors) ) ``` ### 3. Type Safety Full TypeScript inference with no type assertions needed: ```typescript const result = ifElse( (n: number) => n > 0, (n) => `positive: ${n}`, // TypeScript knows n is number (n) => `negative: ${n}` // TypeScript knows n is number ) // result is automatically typed as (n: number) => string ``` ### 4. Integration with Result and Option Works naturally with error handling types: ```typescript import { cond } from '@ontologyio/receta/function' import { unwrapOr } from '@ontologyio/receta/option' const getMessage = cond([ [(s) => s === 'loading', () => 'Please wait...'], [(s) => s === 'success', () => 'Done!'], [(s) => s === 'error', () => 'Failed'] ]) const message = unwrapOr(getMessage(state), 'Unknown state') ``` ## Common Patterns ### State Machines ```typescript type State = 'idle' | 'loading' | 'success' | 'error' const handleState = cond([ [(s) => s === 'idle', () => startLoading()], [(s) => s === 'loading', () => showSpinner()], [(s) => s === 'success', () => displayData()], [(s) => s === 'error', () => showError()] ]) ``` ### Validation Pipelines ```typescript import { piped } from 'remeda' const validateUser = piped( when(emailInvalid, normalizeEmail), unless(hasUsername, deriveUsernameFromEmail), when(passwordWeak, strengthenPassword), unless(ageProvided, setDefaultAge) ) ``` ### Data Aggregation ```typescript const analyzeData = converge>( (total, count, max, min) => ({ total, count, average: total / count, max, min, range: max - min }), [ (data) => sum(data), (data) => data.length, (data) => Math.max(...data), (data) => Math.min(...data) ] ) ``` ### Debugging Pipelines ```typescript import { piped } from 'remeda' const processData = piped( fetchData, tap((data) => logger.debug('Fetched:', data.length, 'items')), transform, tap((data) => logger.debug('Transformed:', data)), validate, tap((data) => logger.debug('Validated:', data.isValid)) ) ``` ## Quick Start ```typescript import * as R from 'remeda' import { ifElse, when, unless, cond, compose, converge, juxt, partial, flip, unary, tap, tryCatch, memoize } from '@ontologyio/receta/function' // 1. Conditional logic const classify = ifElse( (age: number) => age >= 18, (age) => ({ status: 'adult', age }), (age) => ({ status: 'minor', age }) ) // 2. Composition const getStats = juxt([ (nums: number[]) => nums.length, (nums: number[]) => Math.max(...nums), (nums: number[]) => Math.min(...nums) ]) // 3. Partial application const logError = partial( (level: string, msg: string) => `[${level}] ${msg}`, 'ERROR' ) // 4. Safe execution const parseJSON = tryCatch( (str: string) => JSON.parse(str), (err) => `Parse failed: ${err}` ) // 5. Debug pipeline const process = R.pipe( data, tap((x) => console.log('Input:', x)), transform, tap((x) => console.log('Output:', x)) ) ``` ## Next Steps - **[Conditionals Guide](./conditionals.mdx)** - Deep dive into ifElse, when, unless, cond - **[Composition Guide](./composition.mdx)** - Master compose, converge, juxt, ap - **[Partial Application Guide](./partial-application.mdx)** - Learn partial, flip, arity controls - **[Control Flow Guide](./control-flow.mdx)** - Explore tap, tryCatch, memoize - **[Integration Guide](./integration.mdx)** - Combine with Result, Option, and Remeda - **[Real-World Patterns](./real-world-patterns.mdx)** - Production-ready examples - **[API Reference](../reference/function.mdx)** - Complete function signatures ## Philosophy The Function module follows these design principles: 1. **Declarative over Imperative**: Express *what* you want, not *how* to do it 2. **Composition over Complexity**: Build complex behavior from simple pieces 3. **Explicitness over Magic**: Clear function names and predictable behavior 4. **Type Safety First**: Leverage TypeScript's type system fully 5. **Practical over Academic**: Solve real problems with pragmatic solutions These combinators aren't just academic exercisesβ€”they're tools for writing cleaner, more maintainable production code. --- # Composition Guide > Build complex transformations by combining simple functions. ## Overview Function composition is about creating new functions by combining existing ones. The Function module provides four powerful composition utilities: `compose`, `converge`, `juxt`, and `ap`. ## compose - Right-to-Left Composition The `compose` function applies functions from right to left (mathematical composition). ### Signature ```typescript title="Signature" function compose(fn1: (a: A) => B): (a: A) => B function compose(fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C // ... up to 5 functions ``` ### Basic Usage ```typescript import { compose } from '@ontologyio/receta/function' const addOne = (n: number) => n + 1 const double = (n: number) => n * 2 const square = (n: number) => n * n const f = compose(square, double, addOne) f(2) // => square(double(addOne(2))) => square(double(3)) => square(6) => 36 ``` ### When to Use compose vs pipe ```typescript import { pipe } from 'remeda' // compose: right-to-left (mathematical notation) const f = compose(c, b, a) // c(b(a(x))) // pipe: left-to-right (more intuitive for data pipelines) const g = pipe(x, a, b, c) // c(b(a(x))) // Prefer pipe for data transformation const result = pipe( data, transform, validate, save ) // Use compose when thinking mathematically const tidy = compose(finalize, clean, prepare) ``` ### Real-World Example ```typescript const exclaim = (s: string) => `${s}!` const toUpper = (s: string) => s.toUpperCase() const trim = (s: string) => s.trim() const shout = compose(exclaim, toUpper, trim) shout(' hello world ') // => 'HELLO WORLD!' ``` ## converge - Apply Multiple Functions, Combine Results The `converge` combinator applies multiple functions to the same input, then passes all results to a combining function. ### Signature ```typescript function converge( after: (...args: Args) => R, fns: FunctionTuple ): (value: T) => R ``` ### Basic Usage TypeScript can't infer the input type from the list of functions, so a standalone `converge` needs its type arguments: the input, the results of each function, and the final result. Inside a `pipe`, the input type comes from the pipeline and they can be left out. ```typescript import { converge } from '@ontologyio/receta/function' // Calculate average const average = converge( (sum, length) => sum / length, [ (nums) => nums.reduce((a, b) => a + b, 0), (nums) => nums.length ] ) average([1, 2, 3, 4, 5]) // => 3 ``` ### When to Use converge Use `converge` when you need to: - Derive multiple values from a single input - Build objects from different transformations - Combine statistics or metrics ```typescript // Building summary objects const buildSummary = converge>( (total, count, max, min) => ({ total, count, average: total / count, max, min, range: max - min }), [ (nums: number[]) => nums.reduce((a, b) => a + b, 0), (nums: number[]) => nums.length, (nums: number[]) => Math.max(...nums), (nums: number[]) => Math.min(...nums) ] ) buildSummary([1, 5, 3, 9, 2]) // => { total: 20, count: 5, average: 4, max: 9, min: 1, range: 8 } ``` ### Real-World Example: Profile Building ```typescript interface User { firstName: string lastName: string email: string scores: number[] } interface Profile { fullName: string username: string domain: string stats: { totalScore: number averageScore: number maxScore: number } } const buildProfile = converge( (fullName, username, domain, stats) => ({ fullName, username, domain, stats }), [ (user: User) => `${user.firstName} ${user.lastName}`, (user: User) => user.email.split('@')[0]!, (user: User) => user.email.split('@')[1]!, (user: User) => { const total = user.scores.reduce((a, b) => a + b, 0) const avg = total / user.scores.length return { totalScore: total, averageScore: Math.round(avg * 10) / 10, maxScore: Math.max(...user.scores) } } ] ) const user: User = { firstName: 'Alice', lastName: 'Johnson', email: 'alice.johnson@example.com', scores: [92, 88, 95, 87, 91] } console.log(buildProfile(user)) // => { // fullName: 'Alice Johnson', // username: 'alice.johnson', // domain: 'example.com', // stats: { totalScore: 453, averageScore: 90.6, maxScore: 95 } // } ``` ## juxt - Apply Functions in Parallel The `juxt` combinator applies multiple functions to the same input and collects all results in an array. ### Signature ```typescript function juxt any)[]>( fns: Fns ): (...args: Parameters) => ReturnTypes ``` ### Basic Usage ```typescript import { juxt } from '@ontologyio/receta/function' const analyze = juxt([ (nums: number[]) => nums.length, (nums: number[]) => Math.min(...nums), (nums: number[]) => Math.max(...nums), (nums: number[]) => nums.reduce((a, b) => a + b, 0) / nums.length ]) analyze([1, 2, 3, 4, 5]) // => [5, 1, 5, 3] // [length, min, max, average] ``` ### Difference Between juxt and converge ```typescript const sum = (nums: number[]) => nums.reduce((a, b) => a + b, 0) const count = (nums: number[]) => nums.length const max = (nums: number[]) => Math.max(...nums) const data = [1, 2, 9, 3, 0] // juxt: returns array of results const metrics = juxt([sum, count, max]) metrics(data) // => [15, 5, 9] // converge: combines results with a function const summary = converge>( (sum, count, max) => ({ sum, count, max }), [sum, count, max] ) summary(data) // => { sum: 15, count: 5, max: 9 } ``` ### Real-World Example: Data Analytics ```typescript interface SalesRecord { id: string amount: number region: string } const extractMetrics = juxt([ (records: SalesRecord[]) => records.length, (records: SalesRecord[]) => records.reduce((sum, r) => sum + r.amount, 0), (records: SalesRecord[]) => Math.max(...records.map(r => r.amount)), (records: SalesRecord[]) => Math.min(...records.map(r => r.amount)), (records: SalesRecord[]) => new Set(records.map(r => r.region)).size ]) const sales: SalesRecord[] = [ { id: '1', amount: 1200, region: 'North' }, { id: '2', amount: 800, region: 'South' }, { id: '3', amount: 1500, region: 'North' }, { id: '4', amount: 950, region: 'East' }, { id: '5', amount: 2000, region: 'West' } ] const [count, total, max, min, uniqueRegions] = extractMetrics(sales) console.log({ count, total, max, min, uniqueRegions }) // => { count: 5, total: 6450, max: 2000, min: 800, uniqueRegions: 4 } ``` ## ap - Applicative Apply The `ap` combinator applies an array of functions to an array of values, creating all possible combinations. ### Signature ```typescript function ap( fns: readonly ((value: T) => U)[] ): (values: readonly T[]) => U[] ``` ### Basic Usage ```typescript import { ap } from '@ontologyio/receta/function' const fns = [ (n: number) => n + 1, (n: number) => n * 2, (n: number) => n * n ] ap(fns, [1, 2, 3]) // => [ // 2, 3, 4, // +1 to each // 2, 4, 6, // *2 to each // 1, 4, 9 // square each // ] ``` ### When to Use ap The `ap` function is less commonly used than `converge` or `juxt`, but useful for: - Applying multiple validators to multiple inputs - Testing multiple transformations - Cross-product operations ### Real-World Example: Validation Matrix ```typescript const validators = [ (s: string) => s.length > 0, (s: string) => s.length < 100, (s: string) => /^[a-z]+$/i.test(s) ] const inputs = ['hello', '', 'test123'] const results = ap(validators, inputs) // => [ // true, false, true, // length > 0 // true, true, true, // length < 100 // true, false, false // only letters // ] // Reshape into validation matrix const matrix = inputs.map((input, i) => ({ input, validations: { notEmpty: results[i], notTooLong: results[inputs.length + i], lettersOnly: results[inputs.length * 2 + i] } })) ``` ## Combining Composition Functions You can nest these combinators for powerful transformations: ```typescript import { converge, juxt } from '@ontologyio/receta/function' type Analysis = { stats: Record; uniqueCount: number } // Extract multiple metrics, then build summary const analyzeData = converge( (metrics, uniqueValues) => ({ stats: { count: metrics[0], sum: metrics[1], avg: metrics[1] / metrics[0], max: metrics[2], min: metrics[3] }, uniqueCount: uniqueValues }), [ juxt([ (data: number[]) => data.length, (data: number[]) => data.reduce((a, b) => a + b, 0), (data: number[]) => Math.max(...data), (data: number[]) => Math.min(...data) ]), (data: number[]) => new Set(data).size ] ) analyzeData([1, 2, 2, 3, 3, 3, 4, 5]) // => { // stats: { count: 8, sum: 23, avg: 2.875, max: 5, min: 1 }, // uniqueCount: 5 // } ``` ## Best Practices ### 1. Name Your Functions ```typescript // ❌ Unclear const fn = converge((a, b) => a + b, [f1, f2]) // βœ… Clear intent const calculateTotal = converge( (subtotal, tax) => subtotal + tax, [getSubtotal, calculateTax] ) ``` ### 2. Keep Functions Small ```typescript // βœ… Small, focused functions const getFullName = (user: User) => `${user.firstName} ${user.lastName}` const getUsername = (user: User) => user.email.split('@')[0]! const getDomain = (user: User) => user.email.split('@')[1]! const buildProfile = converge( (name, username, domain) => ({ name, username, domain }), [getFullName, getUsername, getDomain] ) ``` ### 3. Type Your Combinators ```typescript // βœ… Explicit types help readability const buildSummary = converge< number[], [number, number, number, number], Summary >( (total, count, max, min): Summary => ({ total, count, max, min, avg: total / count }), [ (nums) => R.sum(nums), (nums) => nums.length, (nums) => Math.max(...nums), (nums) => Math.min(...nums) ] ) ``` ## Next Steps - **[Partial Application Guide](./partial-application.mdx)** - Learn partial, flip, arity - **[Control Flow Guide](./control-flow.mdx)** - Explore tap, tryCatch, memoize - **[API Reference](../reference/function.mdx)** - Complete function signatures --- # Conditionals Guide > Replace if-else chains with declarative conditional combinators. ## Overview Conditional combinators let you express branching logic as composable functions instead of imperative control structures. This makes your code more declarative, testable, and easier to compose in pipelines. ## ifElse - Binary Branching The `ifElse` combinator is the functional equivalent of the ternary operator (`? :`). ### Signature ```typescript function ifElse( predicate: (value: T) => boolean, onTrue: (value: T) => U, onFalse: (value: T) => U ): (value: T) => U ``` ### Basic Usage ```typescript import { ifElse } from '@ontologyio/receta/function' // Classification logic const classify = ifElse( (n: number) => n >= 0, (n) => 'positive', (n) => 'negative' ) classify(5) // => 'positive' classify(-3) // => 'negative' classify(0) // => 'positive' ``` ### Comparison with Traditional Code ```typescript // ❌ Traditional ternary const result = age >= 18 ? { status: 'adult', age } : { status: 'minor', age } // βœ… ifElse combinator (more reusable) const classifyAge = ifElse( (age: number) => age >= 18, (age) => ({ status: 'adult', age }), (age) => ({ status: 'minor', age }) ) const result = classifyAge(age) ``` ### Real-World Example: User Access Control ```typescript import { pipe } from 'remeda' import { ifElse } from '@ontologyio/receta/function' interface User { id: string role: 'admin' | 'user' | 'guest' permissions: string[] } interface AccessGrant { canAccess: boolean level: 'full' | 'limited' | 'none' user: User } const grantAccess = ifElse( (user: User) => user.role === 'admin', (user): AccessGrant => ({ canAccess: true, level: 'full', user }), (user): AccessGrant => ({ canAccess: user.permissions.includes('read'), level: user.permissions.length > 0 ? 'limited' : 'none', user }) ) const adminUser: User = { id: '1', role: 'admin', permissions: [] } const regularUser: User = { id: '2', role: 'user', permissions: ['read'] } console.log(grantAccess(adminUser)) // => { canAccess: true, level: 'full', user: ... } console.log(grantAccess(regularUser)) // => { canAccess: true, level: 'limited', user: ... } ``` ## when - Conditional Transformation The `when` combinator applies a transformation only when a condition is met; otherwise, it returns the original value. ### Signature ```typescript function when( predicate: (value: T) => boolean, fn: (value: T) => T ): (value: T) => T ``` ### Basic Usage ```typescript import { when } from '@ontologyio/receta/function' // Only transform negative numbers const ensurePositive = when( (n: number) => n < 0, (n) => Math.abs(n) ) ensurePositive(-5) // => 5 ensurePositive(3) // => 3 ensurePositive(0) // => 0 ``` ### Chaining Multiple `when` Calls ```typescript import { piped } from 'remeda' const normalizeText = piped( when( (s: string) => s.length === 0, () => 'default' ), when( (s: string) => s.startsWith(' ') || s.endsWith(' '), (s) => s.trim() ), when( (s: string) => s.toLowerCase() === s, (s) => s.charAt(0).toUpperCase() + s.slice(1) ) ) normalizeText('') // => 'Default' normalizeText(' hello ') // => 'Hello' normalizeText('world') // => 'World' normalizeText('CAPS') // => 'CAPS' (already has uppercase) ``` ### Real-World Example: Form Sanitization ```typescript import { pipe } from 'remeda' import { when } from '@ontologyio/receta/function' interface FormData { email: string username: string bio?: string } const sanitizeForm = (data: FormData): FormData => pipe( data, // Normalize email when( (d) => d.email.includes(' '), (d) => ({ ...d, email: d.email.trim().toLowerCase() }) ), // Add default bio if missing when( (d) => !d.bio || d.bio.length === 0, (d) => ({ ...d, bio: 'No bio provided' }) ), // Truncate long bios when( (d) => d.bio !== undefined && d.bio.length > 500, (d) => ({ ...d, bio: d.bio!.slice(0, 497) + '...' }) ) ) const form: FormData = { email: ' Alice@Example.com ', username: 'alice', bio: 'A'.repeat(600) } console.log(sanitizeForm(form)) // => { // email: 'alice@example.com', // username: 'alice', // bio: 'AAA...AAA...' (truncated to 500 chars) // } ``` ## unless - Inverse Conditional The `unless` combinator is the inverse of `when`: it applies a transformation when the condition is **not** met. ### Signature ```typescript function unless( predicate: (value: T) => boolean, fn: (value: T) => T ): (value: T) => T ``` ### Basic Usage ```typescript import { unless } from '@ontologyio/receta/function' // Ensure value is wrapped in array const ensureArray = unless( Array.isArray, (value) => [value] ) ensureArray([1, 2, 3]) // => [1, 2, 3] ensureArray(5) // => [5] ensureArray('hello') // => ['hello'] ``` ### When to Use `when` vs `unless` Use **when** if the condition reads naturally: ```typescript // βœ… Reads clearly when(isEmpty, fillWithDefault) when(isNegative, makePositive) ``` Use **unless** if the inverse reads more naturally: ```typescript // βœ… More natural to read unless(hasProtocol, addProtocol) unless(isAuthenticated, redirectToLogin) // ❌ Less natural when((user) => !isAuthenticated(user), redirectToLogin) ``` ### Real-World Example: Configuration Defaults ```typescript import { pipe } from 'remeda' import { unless } from '@ontologyio/receta/function' interface Config { apiUrl?: string timeout?: number retries?: number apiKey?: string } const applyDefaults = (config: Config): Config => pipe( config, unless( (c) => 'apiUrl' in c && c.apiUrl !== undefined, (c) => ({ ...c, apiUrl: 'https://api.example.com' }) ), unless( (c) => 'timeout' in c && c.timeout !== undefined, (c) => ({ ...c, timeout: 5000 }) ), unless( (c) => 'retries' in c && c.retries !== undefined, (c) => ({ ...c, retries: 3 }) ), unless( (c) => 'apiKey' in c && c.apiKey !== undefined, (c) => ({ ...c, apiKey: process.env.API_KEY || 'default-key' }) ) ) const userConfig: Config = { timeout: 10000 } console.log(applyDefaults(userConfig)) // => { // apiUrl: 'https://api.example.com', // timeout: 10000, // User-provided value preserved // retries: 3, // apiKey: 'default-key' // } ``` ## cond - Multi-Way Conditional The `cond` combinator handles multiple conditions in sequence, returning `Some` with the first match or `None` if no conditions match. ### Signature ```typescript type CondPair = readonly [ predicate: (value: T) => boolean, fn: (value: T) => U ] function cond( pairs: readonly CondPair[] ): (value: T) => Option ``` ### Basic Usage ```typescript import { cond } from '@ontologyio/receta/function' import { unwrapOr } from '@ontologyio/receta/option' const classifyNumber = cond([ [(n) => n < 0, (n) => 'negative'], [(n) => n === 0, () => 'zero'], [(n) => n > 0 && n < 10, (n) => 'small positive'], [(n) => n >= 10, (n) => 'large positive'] ]) unwrapOr(classifyNumber(-5), 'unknown') // => 'negative' unwrapOr(classifyNumber(0), 'unknown') // => 'zero' unwrapOr(classifyNumber(3), 'unknown') // => 'small positive' unwrapOr(classifyNumber(100), 'unknown') // => 'large positive' ``` ### Comparison with Switch Statement ```typescript // ❌ Traditional switch function getStatusMessageSwitch(code: number): string { switch (true) { case code >= 200 && code < 300: return 'Success' case code >= 300 && code < 400: return 'Redirect' case code >= 400 && code < 500: return 'Client Error' case code >= 500: return 'Server Error' default: return 'Unknown' } } // βœ… cond combinator (more functional) const getStatusMessage = cond([ [(code) => code >= 200 && code < 300, () => 'Success'], [(code) => code >= 300 && code < 400, () => 'Redirect'], [(code) => code >= 400 && code < 500, () => 'Client Error'], [(code) => code >= 500, () => 'Server Error'] ]) const message = unwrapOr(getStatusMessage(404), 'Unknown') ``` ### Real-World Example: State Machine ```typescript import { cond } from '@ontologyio/receta/function' import { unwrapOr } from '@ontologyio/receta/option' type OrderState = | 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' interface Order { id: string state: OrderState items: number total: number } interface StateAction { message: string canCancel: boolean nextStates: OrderState[] } const getOrderAction = cond([ [ (order) => order.state === 'pending', (order) => ({ message: `Order ${order.id} is pending payment`, canCancel: true, nextStates: ['processing', 'cancelled'] }) ], [ (order) => order.state === 'processing', (order) => ({ message: `Processing ${order.items} items (total: $${order.total})`, canCancel: true, nextStates: ['shipped', 'cancelled'] }) ], [ (order) => order.state === 'shipped', (order) => ({ message: `Order ${order.id} is on the way`, canCancel: false, nextStates: ['delivered'] }) ], [ (order) => order.state === 'delivered', (order) => ({ message: `Order ${order.id} was delivered`, canCancel: false, nextStates: [] }) ], [ (order) => order.state === 'cancelled', (order) => ({ message: `Order ${order.id} was cancelled`, canCancel: false, nextStates: [] }) ] ]) const order: Order = { id: 'ORD-123', state: 'processing', items: 3, total: 99.99 } const action = unwrapOr(getOrderAction(order), { message: 'Unknown state', canCancel: false, nextStates: [] }) console.log(action) // => { // message: 'Processing 3 items (total: $99.99)', // canCancel: true, // nextStates: ['shipped', 'cancelled'] // } ``` ### Handling No Matches Since `cond` returns an `Option`, you always handle the "no match" case explicitly: ```typescript import { cond } from '@ontologyio/receta/function' import { match } from '@ontologyio/receta/option' const result = pipe( value, cond(pairs), match({ onSome: (value) => `Matched: ${value}`, onNone: () => 'No condition matched' }) ) ``` ## Combining Conditionals You can combine different conditional combinators for complex logic: ```typescript import { pipe } from 'remeda' import { when, unless, ifElse } from '@ontologyio/receta/function' interface Product { price: number inStock: boolean category: string } const applyPricing = (product: Product) => pipe( product, // Add tax unless exempt category unless( (p) => p.category === 'essential', (p) => ({ ...p, price: p.price * 1.2 }) ), // Round to 2 decimals when( (p) => p.price % 1 !== 0, (p) => ({ ...p, price: Math.round(p.price * 100) / 100 }) ), // Apply discount based on stock (p) => ({ ...p, price: ifElse( (prod: Product) => prod.inStock, (prod) => prod.price, (prod) => prod.price * 0.9 // 10% discount if out of stock )(p) }) ) ``` ## Best Practices ### 1. Keep Predicates Pure ```typescript // ❌ Avoid side effects in predicates let counter = 0 when((n) => { counter++; return n > 0 }, transform) // βœ… Pure predicates when((n) => n > 0, transform) ``` ### 2. Use Descriptive Names ```typescript // ❌ Unclear const fn = when((x: number) => x < 0, (x) => -x) // βœ… Clear intent const ensurePositive = when( (n: number) => n < 0, (n) => Math.abs(n) ) ``` ### 3. Order `cond` Pairs Carefully ```typescript // βœ… Specific conditions first cond([ [(n) => n === 0, () => 'zero'], // Most specific [(n) => n > 0 && n < 10, () => 'small'], // More specific [(n) => n > 0, () => 'positive'], // Less specific [() => true, () => 'other'] // Catch-all last ]) // ❌ General conditions first will shadow specific ones cond([ [(n) => n > 0, () => 'positive'], // Too general [(n) => n > 0 && n < 10, () => 'small'], // Never reached! ]) ``` ### 4. Prefer `unless` for Negated Conditions ```typescript // ❌ Double negative is confusing when((user) => !user.isAuthenticated, redirectToLogin) // βœ… Positive reading unless((user) => user.isAuthenticated, redirectToLogin) ``` ## Type Safety All conditional combinators maintain full type safety: ```typescript const result = ifElse( (n: number) => n > 0, (n) => `positive: ${n}`, // n is inferred as number (n) => `negative: ${n}` // n is inferred as number ) // result type: (n: number) => string ``` TypeScript will catch type mismatches: ```ts nocheck ifElse( (n: number) => n > 0, (n) => n.toString(), // returns string (n) => n // ❌ Error: must return string ) ``` ## Next Steps - **[Composition Guide](./composition.mdx)** - Learn compose, converge, juxt - **[Integration Guide](./integration.mdx)** - Combine with Result and Option - **[API Reference](../reference/function.mdx)** - Complete function signatures --- # Control Flow Guide > Manage side effects, errors, and performance with tap, tryCatch, and memoize. ## Overview Control flow utilities help you handle cross-cutting concerns like logging, error handling, and performance without disrupting your main logic flow. ## tap - Non-Destructive Side Effects The `tap` function executes a side effect and returns the original value unchanged. ### Signature ```typescript title="Signature" function tap(fn: (value: T) => void): (value: T) => T ``` ### Basic Usage ```typescript import { pipe } from 'remeda' import { tap } from '@ontologyio/receta/function' const result = pipe( 42, tap((x) => console.log('Value:', x)), // Logs: Value: 42 (x) => x * 2 ) // => 84 ``` ### Debugging Pipelines This is `tap`'s killer use case: ```typescript import { pipe, map, filter } from 'remeda' const processData = pipe( [1, 2, 3, 4, 5], tap((data) => console.log('Input:', data)), map((x) => x * 2), tap((data) => console.log('After doubling:', data)), filter((x) => x > 5), tap((data) => console.log('After filtering:', data)) ) // Logs: // Input: [1, 2, 3, 4, 5] // After doubling: [2, 4, 6, 8, 10] // After filtering: [6, 8, 10] ``` ### Real-World Example: Logging Pipeline ```typescript interface User { id: string email: string } const logger = { info: (msg: string, data?: any) => console.log(`[INFO] ${msg}`, data), error: (msg: string, error?: any) => console.error(`[ERROR] ${msg}`, error) } const processUser = (userId: string) => pipe( userId, tap((id) => logger.info('Fetching user', { id })), fetchUser, tap((user) => logger.info('User fetched', { email: user.email })), validateUser, tap((user) => logger.info('User validated', { id: user.id })), saveUser, tap((user) => logger.info('User saved', { id: user.id })) ) ``` ### Analytics and Tracking ```typescript import { piped } from 'remeda' const trackEvent = (event: string, data: any) => { // Send to analytics service analytics.track(event, data) } const createOrder = piped( buildOrder, tap((order) => trackEvent('order_created', { orderId: order.id })), validateOrder, tap((order) => trackEvent('order_validated', { orderId: order.id })), saveOrder, tap((order) => trackEvent('order_saved', { orderId: order.id, total: order.total })) ) ``` ## tryCatch - Safe Function Execution The `tryCatch` function wraps a potentially throwing function and returns a Result. It only catches synchronous throws. An async function comes back as an `Ok` holding its promise, and a rejection isn't caught. For async code, use `tryCatchAsync` from the Result module. ### Signature ```typescript function tryCatch( fn: (...args: Args) => T, onError?: (error: unknown) => E ): (...args: Args) => Result ``` ### Basic Usage ```typescript import { tryCatch } from '@ontologyio/receta/function' import { isOk, unwrap } from '@ontologyio/receta/result' const parseJSON = tryCatch( (str: string) => JSON.parse(str), (error) => `Parse error: ${error}` ) const result1 = parseJSON('{"valid": "json"}') if (isOk(result1)) { console.log(unwrap(result1)) // => { valid: 'json' } } const result2 = parseJSON('invalid json') if (!isOk(result2)) { console.log(result2.error) // => 'Parse error: ...' } ``` ### Creating Safe Versions of Functions ```typescript // Unsafe divide function const divide = (a: number, b: number) => { if (b === 0) throw new Error('Division by zero') return a / b } // Safe version const safeDivide = tryCatch(divide) safeDivide(10, 2) // => Ok(5) safeDivide(10, 0) // => Err(Error('Division by zero')) ``` ### Real-World Example: API Response Parsing ```typescript interface ApiResponse { data: unknown status: number } interface ParsedUser { id: string name: string email: string } const parseUser = tryCatch( (response: ApiResponse): ParsedUser => { if (response.status !== 200) { throw new Error(`HTTP ${response.status}`) } const data = response.data as any if (!data.id || !data.name || !data.email) { throw new Error('Invalid user data') } return { id: data.id, name: data.name, email: data.email } }, (error) => ({ code: 'PARSE_ERROR', message: error instanceof Error ? error.message : String(error) }) ) const response: ApiResponse = { status: 200, data: { id: '1', name: 'Alice', email: 'alice@example.com' } } const result = parseUser(response) ``` ### Combining with Result Operations ```typescript import { pipe } from 'remeda' import { tryCatch } from '@ontologyio/receta/function' import { map, flatMap, unwrapOr } from '@ontologyio/receta/result' const processConfig = (jsonString: string) => pipe( jsonString, tryCatch(JSON.parse), map((config) => ({ ...config, validated: true })), flatMap(validateConfig), unwrapOr({ default: true }) ) ``` ## memoize - Cache Function Results The `memoize` function caches results based on the first argument. ### Signature ```typescript function memoize( fn: (...args: Args) => R, options?: MemoizeOptions ): MemoizedFunction ``` ### Basic Usage ```typescript import { memoize } from '@ontologyio/receta/function' let callCount = 0 const expensive = memoize((n: number) => { callCount++ return n * 2 }) expensive(5) // Computed, callCount = 1 expensive(5) // Cached, callCount still 1 expensive(10) // Computed, callCount = 2 ``` ### Classic Example: Fibonacci ```typescript const fibonacci = memoize((n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) }) // Without memoization: exponential time // With memoization: linear time console.time('First') fibonacci(40) // => 102334155 console.timeEnd('First') // ~10ms console.time('Cached') fibonacci(40) // => 102334155 console.timeEnd('Cached') // ~0ms ``` ### Real-World Example: API Client ```typescript const fetchUser = memoize(async (userId: string) => { const response = await fetch(`/api/users/${userId}`) return response.json() }) // First call: makes HTTP request await fetchUser('user-1') // Second call: returns cached result await fetchUser('user-1') // Different argument: makes new request await fetchUser('user-2') ``` `memoize` also keeps a rejected promise, so a failed request stays failed. `memoizeAsync` from the Memo module drops failures so the next call retries. ### Cache Invalidation Note: The basic `memoize` uses the first argument as cache key and never expires. For more advanced caching (TTL, LRU, custom keys), use the `memo` module: ```typescript import { memoizeBy, memoizeAsync } from '@ontologyio/receta/memo' // Custom cache key const fetchByQuery = memoizeBy( async (params: QueryParams) => fetchData(params), (params) => JSON.stringify(params) ) // TTL cache import { ttlCache } from '@ontologyio/receta/memo' const fetchWithTTL = memoizeAsync( async (id: string) => fetchData(id), { cache: ttlCache(60000) } // 1 minute ) ``` ## Combining Control Flow Utilities You can combine these utilities for powerful pipelines: ```typescript import { piped } from 'remeda' import { tap, tryCatch, memoize } from '@ontologyio/receta/function' const processData = piped( tap((input) => logger.debug('Processing', input)), tryCatch(parseInput), tap((result) => logger.debug('Parsed', result)), map(transform), tap((result) => logger.debug('Transformed', result)) ) // With memoization const processDataCached = memoize(processData) ``` ## Best Practices ### 1. Use tap for Pure Logging ```typescript // βœ… Good: side effects only tap((user) => logger.info('User fetched', user)) // ❌ Bad: modifying data tap((user) => { user.processed = true; return user }) ``` ### 2. Transform Errors in tryCatch ```typescript // βœ… Good: structured error tryCatch( parse, (error) => ({ code: 'PARSE_ERROR', message: error instanceof Error ? error.message : 'Unknown error', original: error }) ) // ❌ Bad: losing error information tryCatch(parse, () => 'Error occurred') ``` ### 3. Document Memoization Behavior ```typescript /** * Fetches user data with caching. * * ⚠️ Results are cached permanently by user ID. * Use `clearCache()` to invalidate. */ const fetchUser = memoize((id: string) => api.getUser(id)) ``` ### 4. Conditional Logging with tap ```typescript const debugTap = process.env.NODE_ENV === 'development' ? tap : (_fn: any) => (x: any) => x // No-op in production pipe( data, debugTap((x) => console.log('Debug:', x)), transform ) ``` ## Next Steps - **[Integration Guide](./integration.mdx)** - Combine with Result and Option - **[Real-World Patterns](./real-world-patterns.mdx)** - Production examples - **[API Reference](../reference/function.mdx)** - Complete function signatures --- # Integration Guide > Combining Function combinators with Result, Option, and Remeda for powerful pipelines. ## Overview The Function module is designed to work seamlessly with Receta's Result and Option types, as well as Remeda's data transformation utilities. This guide shows you how to combine them effectively. ## Integration with Result ### Conditional Logic with Results ```typescript import { pipe } from 'remeda' import { ifElse, when, cond } from '@ontologyio/receta/function' import { ok, err, isOk, map, flatMap } from '@ontologyio/receta/result' import { unwrapOr as unwrapOrOption } from '@ontologyio/receta/option' // Transform Results based on conditions const processResult = (result: Result) => pipe( result, when( isOk, (r) => map(r, (value) => ({ ...value, processed: true })) ) ) // Multi-way error handling with cond const handleError = cond([ [(err) => err.includes('network'), () => 'Network error - please retry'], [(err) => err.includes('auth'), () => 'Authentication failed'], [(err) => err.includes('not found'), () => 'Resource not found'], [() => true, (err) => `Unknown error: ${err}`] ]) const getErrorMessage = (result: Result) => isOk(result) ? 'Success' : unwrapOrOption(handleError(result.error), 'Error occurred') ``` ### Safe Transformation Pipelines ```typescript import { tryCatch } from '@ontologyio/receta/function' import { flatMap, map } from '@ontologyio/receta/result' interface User { id: string config: string // JSON string } const processUser = (user: User) => pipe( user.config, // Parse JSON safely tryCatch((str: string) => JSON.parse(str)), // Transform if successful map((config) => ({ ...user, parsedConfig: config })), // Validate flatMap(validateUser), // Add metadata map((validated: User) => ({ ...validated, processedAt: new Date() })) ) ``` ### Combining tap with Result ```typescript const processWithLogging = (data: string) => pipe( data, tap((input) => logger.info('Input received', { length: input.length })), tryCatch(JSON.parse), tap((result) => { if (isOk(result)) { logger.info('Parse successful') } else { logger.error('Parse failed', result.error) } }), flatMap(validate), tap((result) => logger.info('Validation complete', { isOk: isOk(result) })) ) ``` ## Integration with Option ### Conditional Transformations ```typescript import { cond, when } from '@ontologyio/receta/function' import { some, none, isSome, unwrapOr, map } from '@ontologyio/receta/option' // cond returns Option naturally const classify = cond([ [(n) => n < 0, () => 'negative'], [(n) => n === 0, () => 'zero'], [(n) => n > 0, () => 'positive'] ]) const result = pipe( 5, classify, unwrapOr('unknown') ) // => 'positive' ``` ### Safe Lookups ```typescript import { fromNullable, isSome, map, unwrapOr } from '@ontologyio/receta/option' interface Config { [key: string]: any } const getSetting = (config: Config, key: string): Option => fromNullable(config[key]) const processConfig = (config: Config) => pipe( getSetting(config, 'apiUrl'), when( isSome, (opt) => map(opt, (url: string) => url.trim()) ), unwrapOr('https://api.default.com') ) ``` ## Integration with Remeda ### Combining map/filter with Function Utilities ```typescript import * as R from 'remeda' import { when, tap, converge } from '@ontologyio/receta/function' // Use Remeda's pipe for left-to-right composition const processNumbers = R.pipe( [1, 2, 3, 4, 5, -1, -2], tap((nums) => console.log('Input:', nums)), // Remeda's map with when for conditional transformation R.map(when((n: number) => n < 0, Math.abs)), tap((nums) => console.log('Absolute values:', nums)), // Remeda's filter R.filter((n) => n > 2), tap((nums) => console.log('Filtered (>2):', nums)), // Remeda's reduce R.reduce((sum, n) => sum + n, 0), tap((sum) => console.log('Sum:', sum)) ) // => 12 ``` ### Advanced Pipelines ```typescript import * as R from 'remeda' import { when, tap, converge, juxt } from '@ontologyio/receta/function' interface Product { id: string name: string price: number category: string } // Analyze products using Remeda + Function combinators const analyzeProducts = (products: Product[]) => R.pipe( products, // Use Remeda's map with when for conditional pricing R.map(when( (p: Product) => p.category === 'sale', (p) => ({ ...p, price: p.price * 0.9 }) )), tap((prods) => logger.info('Prices adjusted', { count: prods.length })), // Use converge to extract multiple metrics at once converge( (total, count, categoryCount, avgPrice) => ({ totalValue: total, productCount: count, avgPrice, uniqueCategories: categoryCount }), [ // Use Remeda's sumBy for cleaner sum (prods: Product[]) => R.sumBy(prods, (p) => p.price), (prods: Product[]) => prods.length, (prods: Product[]) => R.pipe(prods, R.map(p => p.category), R.unique).length, (prods: Product[]) => { const total = R.sumBy(prods, (p) => p.price) return total / prods.length } ] ), tap((analytics) => logger.info('Analysis complete', analytics)) ) ``` ## Integration with Predicate Module ### Combining cond with Predicate Builders ```typescript import * as R from 'remeda' import { cond, when, unless } from '@ontologyio/receta/function' import { where, gt, lt, between, and, oneOf } from '@ontologyio/receta/predicate' import { unwrapOr } from '@ontologyio/receta/option' type Product = { id: string name: string price: number stock: number category: string featured: boolean } // Build reusable predicates const isExpensive = where({ price: gt(1000) }) const isAffordable = where({ price: lt(100) }) const isInStock = where({ stock: gt(0) }) const isFeatured = where({ featured: (x) => x === true }) const isElectronics = where({ category: oneOf(['laptop', 'phone', 'tablet']) }) // Use cond with predicates for classification const classifyProduct = cond([ [and(isExpensive, isFeatured, isInStock), (p) => `πŸ’Ž Premium: ${p.name}`], [and(isAffordable, isInStock), (p) => `βœ“ Budget-Friendly: ${p.name}`], [and(isElectronics, isInStock), (p) => `⚑ Tech Deal: ${p.name}`], [isExpensive, (p) => `πŸ’° Luxury: ${p.name}`], [(p) => p.stock === 0, (p) => `βœ— Out of Stock: ${p.name}`], ]) // Process products with predicates const processProducts = (products: Product[]) => R.pipe( products, // Filter using predicate combinators R.filter(and(isInStock, or(isAffordable, isFeatured))), // Classify each product R.map((p) => unwrapOr(classifyProduct(p), `Unknown: ${p.name}`)), tap((results) => logger.info('Classified products', { count: results.length })) ) ``` ### When/Unless with Predicates ```typescript import { where, gt, and } from '@ontologyio/receta/predicate' type User = { age: number verified: boolean premium: boolean } // Define predicates const isAdult = where({ age: gt(18) }) const isVerified = where({ verified: (x) => x === true }) const isPremium = where({ premium: (x) => x === true }) const isFullAccess = and(isAdult, isVerified) // Use predicates in conditionals const processUser = (user: User) => R.pipe( user, // Add verification badge if verified when(isVerified, (u) => ({ ...u, badge: 'verified' })), // Upgrade to premium features if eligible when( and(isFullAccess, isPremium), (u) => ({ ...u, features: ['advanced-analytics', 'priority-support'] }) ), // Add age restriction notice unless adult unless(isAdult, (u) => ({ ...u, notice: 'Parental consent required' })), tap((u) => logger.debug('User processed', { hasFeatures: 'features' in u })) ) ``` ## Integration with Collection Module ### Using nest and groupBy with Function Combinators ```typescript import * as R from 'remeda' import { converge, juxt, tap } from '@ontologyio/receta/function' import { nest, indexByUnique, diff } from '@ontologyio/receta/collection' interface Order { id: string userId: string region: string status: 'pending' | 'shipped' | 'delivered' total: number } // Analyze orders by multiple dimensions const analyzeOrders = (orders: Order[]) => R.pipe( orders, tap((ords) => logger.info('Analyzing orders', { count: ords.length })), // Use converge with collection operations converge( (byRegion, byStatus, metrics) => ({ byRegion, byStatus, metrics }), [ // Group by region using Remeda (ords: Order[]) => R.groupBy(ords, (o) => o.region), // Group by status (ords: Order[]) => R.groupBy(ords, (o) => o.status), // Extract metrics using juxt juxt([ (ords: Order[]) => ords.length, (ords: Order[]) => R.sumBy(ords, (o) => o.total), (ords: Order[]) => R.uniqueBy(ords, (o) => o.userId).length, (ords: Order[]) => R.groupBy(ords, (o) => o.region) ]) ] ), tap((analysis) => { const [total, revenue, uniqueUsers, regions] = analysis.metrics logger.info('Analysis complete', { total, revenue, uniqueUsers, regionCount: Object.keys(regions).length }) }) ) ``` ### Combining diff with Conditional Logic ```typescript import { diff } from '@ontologyio/receta/collection' import { when, tap, ifElse } from '@ontologyio/receta/function' interface Item { id: string name: string quantity: number } // Track inventory changes const syncInventory = (oldItems: Item[], newItems: Item[]) => R.pipe( diff(oldItems, newItems, (item) => item.id), tap((changes) => { logger.info('Inventory changes detected', { added: changes.added.length, removed: changes.removed.length, updated: changes.updated.length }) }), // Process based on change type (changes) => ({ added: R.pipe( changes.added, R.map(when( (item) => item.quantity > 100, (item) => ({ ...item, bulkOrder: true }) )), tap((items) => items.forEach(item => notifyWarehouse('restock', item))) ), removed: R.pipe( changes.removed, tap((items) => items.forEach(item => notifyWarehouse('removed', item))) ), updated: R.pipe( changes.updated, R.map(({ old, new: newItem }) => ifElse( () => newItem.quantity < old.quantity, () => ({ ...newItem, status: 'decreased' }), () => ({ ...newItem, status: 'increased' }), newItem.quantity ) ) ) }) ) ``` ## Real-World Pattern: API Client ```typescript import { pipe } from 'remeda' import { tap, partial } from '@ontologyio/receta/function' import { flatMap, isOk, map, tryCatchAsync, unwrapOr } from '@ontologyio/receta/result' interface ApiConfig { baseUrl: string headers: Record } interface ApiError { status: number message: string timestamp: string } const createApiClient = (config: ApiConfig) => { const fetchApi = async (endpoint: string, options?: RequestInit) => { const url = `${config.baseUrl}${endpoint}` logger.debug('Fetching', { url }) // tryCatch from this module is for sync code. Fetching needs tryCatchAsync. const result = await tryCatchAsync( async () => { const response = await fetch(url, { ...options, headers: { ...config.headers, ...(options?.headers || {}) } }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.json() }, (error): ApiError => ({ status: 500, message: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }) ) return pipe( result, tap((result) => { if (isOk(result)) { logger.debug('Fetch successful') } else { logger.error('Fetch failed', result.error) } }) ) } return { get: partial(fetchApi), post: (endpoint: string, body: any) => fetchApi(endpoint, { method: 'POST', body: JSON.stringify(body) }) } } // Usage const api = createApiClient({ baseUrl: 'https://api.example.com', headers: { 'Authorization': `Bearer ${token}` } }) const user = await pipe( await api.get('/users/123'), map((data: User) => ({ ...data, fetchedAt: new Date() })), flatMap(validateUser), unwrapOr(null) ) ``` ## Real-World Pattern: Form Validation ```typescript import { pipe } from 'remeda' import { when, unless, tap } from '@ontologyio/receta/function' import { ok, err, flatMap, isOk, map } from '@ontologyio/receta/result' interface FormData { email: string password: string age?: number } interface ValidationError { field: string message: string } // Individual validators const validateEmail = (email: string): Result => email.includes('@') ? ok(email.toLowerCase()) : err({ field: 'email', message: 'Invalid email' }) const validatePassword = (password: string): Result => password.length >= 8 ? ok(password) : err({ field: 'password', message: 'Password too short' }) const validateAge = (age?: number): Result => { if (age === undefined) return ok(18) // default return age >= 18 ? ok(age) : err({ field: 'age', message: 'Must be 18 or older' }) } // Normalize form const normalizeForm = (data: FormData): FormData => pipe( data, when( (d) => d.email.includes(' '), (d) => ({ ...d, email: d.email.trim() }) ), unless( (d) => d.age !== undefined, (d) => ({ ...d, age: 18 }) ) ) // Validate entire form const validateForm = (data: FormData) => pipe( data, normalizeForm, tap((form) => logger.debug('Normalized form', form)), // Each field has its own type, so chain them instead of using collect (form) => flatMap(validateEmail(form.email), (email) => flatMap(validatePassword(form.password), (password) => map(validateAge(form.age), (age) => ({ email, password, age })) ) ) ) // Usage const result = validateForm({ email: ' USER@EXAMPLE.COM ', password: 'password123' }) if (isOk(result)) { console.log('Valid:', result.value) } else { console.error('Errors:', result.error) } ``` ## Best Practices ### 1. Prefer Result for Error Details ```typescript // βœ… Result when you need error context const parse = tryCatch( JSON.parse, (error) => ({ code: 'PARSE_ERROR', original: error }) ) // βœ… Option when error details don't matter import { fromNullable } from '@ontologyio/receta/option' const findUser = (id: string): Option => fromNullable(users.find(u => u.id === id)) ``` ### 2. Use tap for Side Effects in Pipelines ```typescript // βœ… Good: non-intrusive logging pipe( data, tap(logInput), transform, tap(logTransformed), validate ) // ❌ Bad: breaks the pipeline pipe( data, (d) => { console.log(d); return d }, transform ) ``` ### 3. Combine Conditionals with Validation ```typescript import { piped } from 'remeda' const processUser = piped( normalizeEmail, when(needsVerification, sendVerificationEmail), flatMap(validateUser), tap(auditLog), unless(isActive, activateUser) ) ``` ### 4. Handle All Result/Option Cases ```typescript // βœ… Always handle both cases const result = pipe( fetchData(), match({ onOk: (data) => `Success: ${data}`, onErr: (error) => `Error: ${error}` }) ) // ❌ Don't unwrap without default const result = unwrap(fetchData()) // Can throw! ``` ## Next Steps - **[Real-World Patterns](./real-world-patterns.mdx)** - More production examples - **[API Reference](../reference/function.mdx)** - Complete function signatures --- # Partial Application Guide > Create specialized functions by pre-filling arguments. ## Overview Partial application lets you create new functions by "filling in" some arguments of an existing function. This is powerful for creating specialized versions of general functions, building configuration systems, and improving code reusability. ## partial - Left-to-Right Partial Application The `partial` function pre-fills arguments from the left (beginning). ### Signature ```typescript function partial( fn: (...args: Args) => R, ...prefilledArgs: any[] ): (...args: any[]) => R ``` ### Basic Usage ```typescript import { partial } from '@ontologyio/receta/function' const greet = (greeting: string, name: string) => `${greeting}, ${name}!` const sayHello = partial(greet, 'Hello') sayHello('Alice') // => 'Hello, Alice!' sayHello('Bob') // => 'Hello, Bob!' ``` ### Multiple Arguments ```typescript const log = (level: string, module: string, message: string) => `[${level}] ${module}: ${message}` // Partial with one argument const logError = partial(log, 'ERROR') logError('Auth', 'Invalid token') // => '[ERROR] Auth: Invalid token' // Partial with two arguments const logUserError = partial(log, 'ERROR', 'UserModule') logUserError('Invalid input') // => '[ERROR] UserModule: Invalid input' ``` ### Real-World Example: API Client ```typescript interface ApiConfig { baseUrl: string headers: Record } const fetchApi = async ( config: ApiConfig, endpoint: string, options?: RequestInit ): Promise => { return fetch(`${config.baseUrl}${endpoint}`, { ...options, headers: { ...config.headers, ...(options?.headers || {}) } }) } // Create specialized API client const prodConfig: ApiConfig = { baseUrl: 'https://api.production.com', headers: { 'Authorization': `Bearer ${process.env.API_KEY}` } } const prodFetch = partial(fetchApi, prodConfig) // Now you can use it without repeating config await prodFetch('/users') await prodFetch('/posts', { method: 'POST', body: JSON.stringify(data) }) ``` ## partialRight - Right-to-Left Partial Application The `partialRight` function pre-fills arguments from the right (end). ### Signature ```typescript function partialRight( fn: (...args: Args) => R, ...prefilledArgs: any[] ): (...args: any[]) => R ``` ### Basic Usage ```typescript import { partialRight } from '@ontologyio/receta/function' const divide = (a: number, b: number) => a / b const divideBy10 = partialRight(divide, 10) divideBy10(100) // => 10 (100 / 10) divideBy10(50) // => 5 (50 / 10) ``` ### When to Use partialRight Use `partialRight` when the data you want to vary comes first: ```typescript // Data varies, format is fixed const formatDate = (date: Date, format: string, locale: string) => date.toLocaleDateString(locale, { dateStyle: format as any }) const formatInUS = partialRight(formatDate, 'short', 'en-US') const formatInFR = partialRight(formatDate, 'short', 'fr-FR') const today = new Date('2026-01-22') formatInUS(today) // => '1/22/26' formatInFR(today) // => '22/01/2026' ``` ### Real-World Example: Event Logging ```typescript const logEvent = ( event: string, userId: string, timestamp: string, metadata: Record ) => ({ event, userId, timestamp, metadata }) // Pre-fill timestamp and metadata for a session const sessionLogger = partialRight( logEvent, new Date().toISOString(), { sessionId: 'abc-123', source: 'web' } ) // Now just specify event and user sessionLogger('page_view', 'user-1') sessionLogger('button_click', 'user-1') sessionLogger('form_submit', 'user-1') ``` ## flip - Reverse Argument Order The `flip` function swaps the first two arguments of a function. ### Signature ```typescript function flip( fn: (a: A, b: B) => R ): (b: B, a: A) => R ``` ### Basic Usage ```typescript import { flip } from '@ontologyio/receta/function' const subtract = (a: number, b: number) => a - b const subtractFrom = flip(subtract) subtract(10, 3) // => 7 (10 - 3) subtractFrom(3, 10) // => 7 (10 - 3, arguments flipped) ``` ### Use Case: Better Partial Application ```typescript const divide = (a: number, b: number) => a / b // Without flip const divideBy10 = (n: number) => divide(n, 10) // With flip + partial (more elegant) const divideBy = flip(divide) const divideBy10 = partial(divideBy, 10) divideBy10(100) // => 10 ``` ### Real-World Example: Data Pipeline ```typescript import { pipe, map } from 'remeda' import { flip, partial } from '@ontologyio/receta/function' const append = (lines: string[], line: string) => [...lines, line] const prepend = (lines: string[], line: string) => [line, ...lines] const addFooter = partial(flip(append), '---End---') const addHeader = partial(flip(prepend), '---Start---') const processLines = pipe( ['line 1', 'line 2', 'line 3'], addHeader, addFooter ) // => ['---Start---', 'line 1', 'line 2', 'line 3', '---End---'] ``` ## Arity Control ### unary - Limit to One Argument Forces a function to accept only its first argument. ```typescript import { unary } from '@ontologyio/receta/function' // The classic parseInt bug const digits = ['1', '2', '3'] digits.map(parseInt) // => [1, NaN, NaN] digits.map(unary(parseInt)) // => [1, 2, 3] ``` **Why does this happen?** ```typescript // Array.map passes (element, index, array) // parseInt reads the first two as (string, radix) // Without unary: parseInt('1', 0) // => 1 (radix 0 defaults to 10) parseInt('2', 1) // => NaN (radix 1 is invalid) parseInt('3', 2) // => NaN (3 is invalid in base 2) // With unary: parseInt('1') // => 1 parseInt('2') // => 2 parseInt('3') // => 3 ``` ### binary - Limit to Two Arguments ```typescript import { binary } from '@ontologyio/receta/function' const sum = (...nums: number[]) => nums.reduce((a, b) => a + b, 0) sum(1, 2, 3, 4) // => 10 // reduce also passes the index and the array, which binary drops const numbers = [1, 2, 3, 4] numbers.reduce(binary(sum)) // => 10 ``` ### nAry - Limit to N Arguments ```typescript import { nAry } from '@ontologyio/receta/function' const log = (...args: any[]) => console.log(...args) const logOne = nAry(1, log) const logThree = nAry(3, log) logOne('a', 'b', 'c') // Logs: a logThree('a', 'b', 'c', 'd') // Logs: a b c ``` ### Real-World Example: Callback Adapters ```typescript // Problem: Library passes extra arguments you don't want const onClick = (event: MouseEvent, index: number, element: HTMLElement) => { // But you only care about the event handleClick(event) } // Solution: Use unary const onClick = unary((event: MouseEvent) => { handleClick(event) }) // Now extra arguments are ignored element.addEventListener('click', onClick) ``` ## Combining Partial Application You can chain partial applications: ```typescript const formatLog = (timestamp: string, level: string, module: string, msg: string) => `${timestamp} [${level}] ${module}: ${msg}` const withTimestamp = partial(formatLog, '2026-01-22T10:00:00Z') const errorLog = partial(withTimestamp, 'ERROR') const userErrorLog = partial(errorLog, 'UserModule') userErrorLog('Failed authentication') // => '2026-01-22T10:00:00Z [ERROR] UserModule: Failed authentication' ``` ## Best Practices ### 1. Design Functions for Partial Application ```typescript // βœ… Good: Config/context first, data last const query = (config: Config, sql: string, params: any[]) => runQuery(config, sql, params) const dbQuery = partial(query, productionConfig) // ❌ Bad: Data first makes partial application awkward const query = (sql: string, params: any[], config: Config) => runQuery(config, sql, params) ``` ### 2. Name Partially Applied Functions Clearly ```typescript // ❌ Unclear const fn = partial(log, 'ERROR') // βœ… Clear intent const logError = partial(log, 'ERROR') const logUserError = partial(logError, 'UserModule') ``` ### 3. Use flip When Data Should Come First ```typescript // If you have: fn(config, data) // But need: fn(data, config) const flipped = flip(fn) const withConfig = partial(flipped, config) // Now: withConfig(data) ``` ### 4. Prefer unary for Common Callbacks ```typescript // βœ… Clean numbers.map(unary(parseInt)) items.forEach(unary(processItem)) // ❌ Verbose numbers.map((x) => parseInt(x)) items.forEach((item) => processItem(item)) ``` ## Type Safety Partial application keeps the return type. The remaining arguments are typed `any[]`, so TypeScript won't check them: ```ts nocheck const greet = (greeting: string, name: string): string => `${greeting}, ${name}!` const sayHello = partial(greet, 'Hello') // TypeScript knows sayHello returns string const result: string = sayHello('Alice') // ❌ Type error const wrong: number = sayHello('Bob') ``` ## Next Steps - **[Control Flow Guide](./control-flow.mdx)** - Learn tap, tryCatch, memoize - **[Integration Guide](./integration.mdx)** - Combine with Result and Option - **[Real-World Patterns](./real-world-patterns.mdx)** - Production examples --- # Real-World Patterns > Production-ready examples using Function combinators to solve common problems. ## Pattern 1: HTTP Client with Retry Logic ```typescript import { pipe } from 'remeda' import { partial } from '@ontologyio/receta/function' import { retry, type RetryError } from '@ontologyio/receta/async' import { mapErr, unwrapOr } from '@ontologyio/receta/result' interface FetchConfig { baseUrl: string timeout: number maxRetries: number } const createHttpClient = (config: FetchConfig) => { const fetchWithRetry = async (endpoint: string, options?: RequestInit) => { const url = `${config.baseUrl}${endpoint}` logger.debug('Fetching', { url }) // retry returns a promise, so await it before handing the Result to pipe const result = await retry( async () => { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), config.timeout) try { const response = await fetch(url, { ...options, signal: controller.signal }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return await response.json() } finally { clearTimeout(timeoutId) } }, { maxAttempts: config.maxRetries, delay: 1000 } ) return pipe( result, mapErr((error: RetryError) => ({ code: 'FETCH_ERROR', message: error.lastError instanceof Error ? error.lastError.message : 'Unknown error', attempts: error.attempts })) ) } return { get: partial(fetchWithRetry), post: (endpoint: string, body: any) => fetchWithRetry(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) } } // Usage const api = createHttpClient({ baseUrl: 'https://api.example.com', timeout: 5000, maxRetries: 3 }) const result = await api.get('/users/123') const user = unwrapOr(result, null) ``` ## Pattern 2: Multi-Stage Data Pipeline ```typescript import * as R from 'remeda' import { when, unless, tap, converge, juxt } from '@ontologyio/receta/function' import { where, and } from '@ontologyio/receta/predicate' type RawEvent = { id: string timestamp: string userId: string eventType: string data: Record } interface ProcessedEvent extends RawEvent { normalized: boolean validated: boolean enriched: boolean } // Define predicates for validation const hasUserId = where({ userId: (x) => x.length > 0 }) const hasEventType = where({ eventType: (x) => x.length > 0 }) const hasTimestamp = where({ timestamp: (x) => x.length > 0 }) const isValidEvent = and(hasUserId, hasEventType, hasTimestamp) // Multi-stage processing pipeline with Remeda const processEvents = (events: RawEvent[]) => R.pipe( events, tap((evts) => metrics.gauge('events.input', evts.length)), // Stage 1: Normalization using Remeda's map + when R.map(when( (evt) => !evt.timestamp.endsWith('Z'), (evt) => ({ ...evt, timestamp: `${evt.timestamp}Z` }) )), R.map((evt) => ({ ...evt, normalized: true })), tap((evts) => logger.debug('Normalized', { count: evts.length })), // Stage 2: Validation using Predicate module R.filter((evt) => { const valid = isValidEvent(evt) if (!valid) { logger.warn('Invalid event', { id: evt.id }) } return valid }), R.map((evt) => ({ ...evt, validated: true } as ProcessedEvent)), tap((evts) => metrics.gauge('events.validated', evts.length)), // Stage 3: Enrichment R.map(unless( (evt) => 'sessionId' in evt.data, (evt) => ({ ...evt, data: { ...evt.data, sessionId: generateSessionId(evt.userId) } }) )), R.map((evt) => ({ ...evt, enriched: true })), // Stage 4: Grouping and Analytics with Remeda (events) => R.pipe( events, // Use converge with Remeda operations converge( (byType, totalCount, uniqueUsers, typeCount) => ({ byType, totalCount, uniqueUsers, eventTypeCount: typeCount, processingComplete: true }), [ // Group using Remeda (evts: ProcessedEvent[]) => R.groupBy(evts, (e) => e.eventType), // Count using length (evts: ProcessedEvent[]) => evts.length, // Unique users using Remeda (evts: ProcessedEvent[]) => R.uniqueBy(evts, (e) => e.userId).length, // Unique event types (evts: ProcessedEvent[]) => R.pipe(evts, R.map(e => e.eventType), R.unique).length ] ) ), tap((result) => { metrics.gauge('events.processed', result.totalCount) metrics.gauge('events.unique_users', result.uniqueUsers) logger.info('Processing complete', result) }) ) ``` ## Pattern 3: State Machine Implementation ```typescript import { cond, when } from '@ontologyio/receta/function' import { unwrapOr } from '@ontologyio/receta/option' type OrderState = 'draft' | 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' interface Order { id: string state: OrderState items: any[] total: number createdAt: Date updatedAt: Date } interface StateTransition { newState: OrderState actions: Array<(order: Order) => Promise> validations: Array<(order: Order) => boolean> } const getTransition = (order: Order, action: string): Option => cond<[Order, string], StateTransition>([ // Submit order: draft -> pending [ ([o, a]) => o.state === 'draft' && a === 'submit', ([o]) => ({ newState: 'pending', actions: [sendConfirmationEmail, createPaymentIntent], validations: [hasItems, hasValidAddress] }) ], // Process payment: pending -> processing [ ([o, a]) => o.state === 'pending' && a === 'process', ([o]) => ({ newState: 'processing', actions: [chargePayment, notifyWarehouse], validations: [hasPaymentMethod] }) ], // Ship order: processing -> shipped [ ([o, a]) => o.state === 'processing' && a === 'ship', ([o]) => ({ newState: 'shipped', actions: [generateTrackingNumber, sendShippingNotification], validations: [allItemsPacked] }) ], // Deliver order: shipped -> delivered [ ([o, a]) => o.state === 'shipped' && a === 'deliver', ([o]) => ({ newState: 'delivered', actions: [sendDeliveryConfirmation, requestReview], validations: [] }) ], // Cancel order: any -> cancelled (with conditions) [ ([o, a]) => a === 'cancel' && ['draft', 'pending'].includes(o.state), ([o]) => ({ newState: 'cancelled', actions: [refundPayment, sendCancellationEmail], validations: [] }) ] ])([order, action]) const transitionOrder = async (order: Order, action: string): Promise> => { const transition = getTransition(order, action) if (!isSome(transition)) { return err(`Invalid transition: ${order.state} -> ${action}`) } const { newState, actions, validations } = unwrap(transition) // Run validations const allValid = validations.every(v => v(order)) if (!allValid) { return err('Validation failed') } // Apply transition const updatedOrder = { ...order, state: newState, updatedAt: new Date() } // Execute actions await Promise.all(actions.map(action => action(updatedOrder))) return ok(updatedOrder) } ``` ## Pattern 4: Complex Form Validation ```typescript import { pipe } from 'remeda' import { when, unless, converge } from '@ontologyio/receta/function' import { ok, err, flatMap, isOk, map } from '@ontologyio/receta/result' interface UserRegistrationForm { email: string password: string confirmPassword: string age: string agreedToTerms: boolean } interface ValidationError { field: string message: string } interface ValidatedForm { email: string password: string age: number agreedToTerms: true } // Field validators const validateEmail = (email: string): Result => { const normalized = email.trim().toLowerCase() if (!normalized.includes('@')) { return err({ field: 'email', message: 'Invalid email format' }) } if (normalized.endsWith('@temporary.com')) { return err({ field: 'email', message: 'Temporary emails not allowed' }) } return ok(normalized) } const validatePassword = (password: string, confirm: string): Result => { if (password.length < 8) { return err({ field: 'password', message: 'Password must be at least 8 characters' }) } if (!/[A-Z]/.test(password)) { return err({ field: 'password', message: 'Password must contain uppercase letter' }) } if (!/[0-9]/.test(password)) { return err({ field: 'password', message: 'Password must contain number' }) } if (password !== confirm) { return err({ field: 'confirmPassword', message: 'Passwords do not match' }) } return ok(password) } const validateAge = (ageStr: string): Result => { const age = parseInt(ageStr, 10) if (isNaN(age)) { return err({ field: 'age', message: 'Age must be a number' }) } if (age < 13) { return err({ field: 'age', message: 'Must be at least 13 years old' }) } if (age > 120) { return err({ field: 'age', message: 'Invalid age' }) } return ok(age) } const validateTerms = (agreed: boolean): Result => agreed ? ok(true) : err({ field: 'agreedToTerms', message: 'Must agree to terms' }) // Normalize form data const normalizeForm = (form: UserRegistrationForm): UserRegistrationForm => pipe( form, when( (f) => f.email.includes(' '), (f) => ({ ...f, email: f.email.trim() }) ), when( (f) => f.age.trim() === '', (f) => ({ ...f, age: '0' }) ) ) // Validate entire form // Each field has its own type, so chain them. The first failure stops the chain. const validateRegistrationForm = (form: UserRegistrationForm): Result => pipe( form, normalizeForm, (normalized) => flatMap(validateEmail(normalized.email), (email) => flatMap(validatePassword(normalized.password, normalized.confirmPassword), (password) => flatMap(validateAge(normalized.age), (age) => map(validateTerms(normalized.agreedToTerms), (agreedToTerms) => ({ email, password, age, agreedToTerms })) ) ) ) ) // Usage const formData: UserRegistrationForm = { email: ' user@EXAMPLE.COM ', password: 'SecurePass123', confirmPassword: 'SecurePass123', age: '25', agreedToTerms: true } const result = validateRegistrationForm(formData) if (isOk(result)) { console.log('Valid:', result.value) // Proceed with registration } else { console.error('Validation error:', result.error) // Show the error to the user } ``` ## Pattern 5: Event-Driven Analytics ```typescript import * as R from 'remeda' import { juxt, converge, tap } from '@ontologyio/receta/function' interface Event { type: string userId: string timestamp: Date properties: Record } // Extract comprehensive analytics using Remeda + Function combinators const analyzeEvents = (events: Event[]) => R.pipe( events, tap((evts) => logger.info('Analyzing events', { count: evts.length })), // Use converge to extract multiple dimensions converge( (byType, byUser, timeline, summary) => ({ byType, byUser, timeline, summary }), [ // Group by event type using Remeda (evts: Event[]) => R.pipe( evts, R.groupBy(e => e.type), R.mapValues(grouped => ({ count: grouped.length, uniqueUsers: R.uniqueBy(grouped, e => e.userId).length })) ), // Group by user (evts: Event[]) => R.pipe( evts, R.groupBy(e => e.userId), R.mapValues(grouped => ({ eventCount: grouped.length, eventTypes: R.pipe(grouped, R.map(e => e.type), R.unique), firstEvent: R.first(grouped)?.timestamp, lastEvent: R.last(grouped)?.timestamp })) ), // Timeline buckets using Remeda (evts: Event[]) => R.pipe( evts, R.groupBy(e => e.timestamp.toISOString().split('T')[0]), R.mapValues(grouped => grouped.length) ), // Overall summary using juxt for parallel extraction juxt([ (evts: Event[]) => evts.length, (evts: Event[]) => R.uniqueBy(evts, e => e.userId).length, (evts: Event[]) => R.pipe(evts, R.map(e => e.type), R.unique).length, (evts: Event[]) => R.first(evts)?.timestamp, (evts: Event[]) => R.last(evts)?.timestamp ]) ] ), tap((analytics) => { const [total, uniqueUsers, uniqueTypes, firstEvent, lastEvent] = analytics.summary logger.info('Analytics complete', { total, uniqueUsers, uniqueTypes, timespan: { start: firstEvent, end: lastEvent } }) }) ) ``` ## Next Steps - **[API Reference](../reference/function.mdx)** - Complete function signatures --- # Installation Add Receta and Remeda, then import each module by its own path. Receta needs TypeScript 5 or later and runs on Node 18 or later, Bun, Deno and in the browser. ```bash bun add @ontologyio/receta remeda ``` ```bash npm install @ontologyio/receta remeda ``` ```bash deno add npm:@ontologyio/receta ``` ## Importing Each module has its own path. There's no import from the package root, so a bundle only ever holds the modules you use. ```ts import { ok, err, map } from '@ontologyio/receta/result' import { fromNullable } from '@ontologyio/receta/option' import { mapAsync, retry } from '@ontologyio/receta/async' ``` The modules are `result`, `option`, `async`, `validation`, `predicate`, `collection`, `object`, `string`, `number`, `memo`, `lens`, `compare` and `function`. ## Types and values `Result`, `Option` and `Validation` are types, not namespaces, so `Result.map(...)` doesn't exist. Import the functions by name: ```ts import { map, type Result } from '@ontologyio/receta/result' const double = (value: Result) => map(value, (n) => n * 2) ``` When two modules share a name, alias one with its module: ```ts import { map } from '@ontologyio/receta/result' import { map as mapOption } from '@ontologyio/receta/option' ``` ## Data first or data last Every transformer works both ways, so it reads well on its own and inside a pipe. ```ts import * as R from 'remeda' import { map, ok } from '@ontologyio/receta/result' map(ok(5), (n) => n * 2) // => Ok(10) R.pipe( ok(5), map((n) => n * 2), ) // => Ok(10) ``` --- # Overview Lenses provide a composable, type-safe way to read and update deeply nested data structures without mutation. Think "dot notation that works for updates" with full immutability guarantees. ## The Problem: Deep Updates Are Painful ### Scenario: Updating Nested State in React You're building a user profile editor in React. The state structure looks like this: ```typescript interface UserProfile { name: string email: string address: { street: string city: string zip: string country: { name: string code: string } } preferences: { theme: 'light' | 'dark' notifications: boolean language: string } } ``` **Task**: User changes their city from "Boston" to "NYC" **Traditional approach with spread operators**: ```typescript // ❌ Nested spread hell - error-prone and verbose const updateCity = (profile: UserProfile, newCity: string) => ({ ...profile, address: { ...profile.address, city: newCity, }, }) // Easy to make mistakes: const broken = (profile: UserProfile, newCity: string) => ({ ...profile, address: { // Forgot to spread! Lost street, zip, country city: newCity, }, }) ``` **4 levels deep? Good luck:** ```typescript // ❌ Update country code - this is painful const updateCountryCode = (profile: UserProfile, code: string) => ({ ...profile, address: { ...profile.address, country: { ...profile.address.country, code, }, }, }) ``` ### How Real Products Handle This Let's look at how successful products structure their state updates: **GitHub's PR Review State** (simplified): ```typescript // GitHub needs to update review states deep in their data structure interface PRState { pr: { number: number reviews: Array<{ id: string author: string state: 'approved' | 'changes_requested' | 'commented' comments: Array<{ id: string; body: string }> }> } } // Traditional approach - updating a comment body const updateComment = ( state: PRState, reviewId: string, commentId: string, newBody: string ): PRState => ({ ...state, pr: { ...state.pr, reviews: state.pr.reviews.map((review) => review.id === reviewId ? { ...review, comments: review.comments.map((comment) => comment.id === commentId ? { ...comment, body: newBody } : comment ), } : review ), }, }) // 😰 Complex, hard to read, easy to mess up ``` **Stripe Dashboard Settings** (simplified): ```typescript // Stripe manages complex nested settings interface StripeSettings { account: { business: { name: string taxId: string address: { line1: string city: string country: string } } branding: { logo: string primaryColor: string accentColor: string } } } // Updating the business address city const updateBusinessCity = ( settings: StripeSettings, city: string ): StripeSettings => ({ ...settings, account: { ...settings.account, business: { ...settings.account.business, address: { ...settings.account.business.address, city, }, }, }, }) // 🀯 5 levels of spreading - this is maintenance hell ``` ## The Solution: Lenses Lenses solve this by providing **composable accessors** that work for both reading AND writing: ```typescript import { prop, path, view, set, over } from '@ontologyio/receta/lens' import * as R from 'remeda' // Define lenses once const cityLens = path('address.city') const countryCodeLens = path('address.country.code') // Reading is simple const city = view(cityLens, profile) // "Boston" // Updating is just as simple - immutable by default const updated = set(cityLens, 'NYC', profile) // Transformations work too const uppercase = over(cityLens, (city) => city.toUpperCase(), profile) // Compose in pipes - React setState style setState((state) => R.pipe( state, set(cityLens, 'NYC'), set(countryCodeLens, 'US'), over(prop('email'), (email) => email.toLowerCase()) ) ) ``` ### GitHub Example with Lenses ```typescript import { prop, compose, over } from '@ontologyio/receta/lens' type Review = PRState['pr']['reviews'][number] // Define lenses for the structure const prLens = prop('pr') const reviewsLens = prop('reviews') // Now updates are clear and composable const updateReviewState = ( state: PRState, reviewId: string, newState: Review['state'] ) => { const reviewsPath = compose(prLens, reviewsLens) return over(reviewsPath, (reviews) => reviews.map((review) => review.id === reviewId ? { ...review, state: newState } : review ) )(state) } // βœ… Much clearer intent, no nested spreads ``` ### Stripe Example with Lenses ```typescript import { path, set } from '@ontologyio/receta/lens' import * as R from 'remeda' // Define lenses once const businessCityLens = path( 'account.business.address.city' ) const brandingLogoLens = path('account.branding.logo') const primaryColorLens = path( 'account.branding.primaryColor' ) // Update multiple deep fields - clean and readable const updateSettings = (settings: StripeSettings) => R.pipe( settings, set(businessCityLens, 'San Francisco'), set(brandingLogoLens, 'https://cdn.stripe.com/new-logo.png'), set(primaryColorLens, '#6772E5') ) // βœ… Clean, composable, no spread operator hell ``` ## Why Lenses Over Alternatives? ### Problem 1: Spread Operators Don't Compose ```typescript // ❌ Can't reuse this logic const updateCity = (profile: UserProfile) => ({ ...profile, address: { ...profile.address, city: 'NYC', }, }) // ❌ Have to repeat the whole pattern for country const updateCountry = (profile: UserProfile) => ({ ...profile, address: { ...profile.address, country: { ...profile.address.country, code: 'US', }, }, }) // βœ… Lenses compose naturally const addressLens = prop('address') const cityLens = prop('city') const countryLens = prop('country') const codeLens = prop('code') const addressCityLens = compose(addressLens, cityLens) const countryCodeLens = compose(compose(addressLens, countryLens), codeLens) // Or use path for convenience const cityPath = path('address.city') const codePath = path('address.country.code') ``` ### Problem 2: Spread Operators Lose Type Information ```typescript // ❌ TypeScript can't help you here const broken = (profile: UserProfile) => ({ ...profile, address: { // Oops, forgot to spread address // TypeScript doesn't catch this easily cty: 'NYC', // Typo! Should be 'city' }, }) // βœ… Lenses built from prop are checked against the type const addressLens = prop('address') const cityLens = prop('city') set(compose(addressLens, cityLens), 'NYC', profile) // a typo in 'city' won't compile ``` `path` is shorter but looser: its string isn't checked, so `path('address.cty')` compiles. ### Problem 3: Spread Operators Don't Work in Pipelines ```typescript // ❌ Can't chain spread updates naturally const result = updateCity(updateCountry(updateEmail(profile))) // ❌ Or you end up with this mess const result = { ...profile, email: profile.email.toLowerCase(), address: { ...profile.address, city: 'NYC', country: { ...profile.address.country, code: 'US', }, }, } // βœ… Lenses work perfectly in pipes const result = R.pipe( profile, over(emailLens, (email) => email.toLowerCase()), set(cityLens, 'NYC'), set(countryCodeLens, 'US') ) ``` ## Real-World Use Cases ### 1. React State Management ```typescript import { useState } from 'react' // Complex state in React const [appState, setAppState] = useState(initialState) // Update with lenses const updateTheme = () => { setAppState((state) => set(themeLens, 'dark', state)) } const incrementCounter = () => { setAppState((state) => over(counterLens, (n) => n + 1, state)) } ``` ### 2. Redux Reducers ```typescript // Instead of nested spreads in reducers const reducer = (state: State, action: Action): State => { switch (action.type) { case 'UPDATE_USER_CITY': return set(cityLens, action.payload, state) case 'INCREMENT_COUNTER': return over(counterLens, (n) => n + 1, state) default: return state } } ``` ### 3. Form Field Updates ```tsx interface FormState { user: { firstName: string lastName: string email: string } billing: { cardNumber: string expiryDate: string cvv: string } } // Create lenses for each field const firstNameLens = path('user.firstName') const emailLens = path('user.email') const cardLens = path('billing.cardNumber') // Generic field updater const updateField = (lens: Lens, value: T) => { setFormState((state) => set(lens, value, state)) } // Use in event handlers updateField(emailLens, e.target.value)} /> ``` ### 4. API Response Transformation ```typescript // Transform nested API response interface APIResponse { data: { user: { profile: { displayName: string avatar: string } } } } const displayNameLens = path( 'data.user.profile.displayName' ) // Normalize display names const normalized = over( displayNameLens, (name) => name.trim().toLowerCase(), apiResponse ) ``` ### 5. Configuration Management ```typescript // Deep configuration objects interface Config { api: { baseUrl: string timeout: number retry: { maxAttempts: number delay: number } } features: { darkMode: boolean analytics: boolean } } // Update config with environment variables const retryAttemptsLens = path('api.retry.maxAttempts') const analyticsLens = path('features.analytics') const productionConfig = R.pipe( developmentConfig, set(retryAttemptsLens, 5), set(analyticsLens, true) ) ``` ## Mental Model: Focusing Glasses Think of a lens as **focusing glasses** that let you zoom into a specific part of a data structure: ```text β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ UserProfile (whole) β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ address β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ city β”‚ β”‚ β”‚ ← Lens focuses here β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` **Operations**: - `view(lens, data)` - Look through the lens (read) - `set(lens, value, data)` - Change what you see (write) - `over(lens, fn, data)` - Transform what you see (modify) The lens **remembers the path** back, so it can: 1. Extract the focused value 2. Update just that value 3. Reconstruct the entire structure immutably ## When to Use Lenses ### βœ… Use Lenses When: - **Nested updates**: More than 2 levels deep - **Repeated patterns**: Same update logic used multiple times - **Complex state**: React/Redux/Zustand state management - **Transformations**: Need to modify values, not just set them - **Type safety matters**: Want compiler to catch errors - **Code reuse**: Want to abstract update patterns - **Composition**: Building complex updates from simple ones ### ❌ Don't Use Lenses For: - **Shallow updates**: Single-level object updates (just use spread) - **One-off operations**: Used once and never again - **Simple assignments**: Direct property access is clearer - **Performance-critical paths**: Lens creation has tiny overhead (usually negligible) - **Mutation is ok**: Working with mutable data structures intentionally ### Examples ```typescript // ❌ Overkill - just use spread const updateName = (user: User, name: string) => ({ ...user, name }) // βœ… Worth it - deeply nested const updateCity = set(path('address.city'), 'NYC') // ❌ Overkill - one-time operation const temp = set(someLens, value, data) // βœ… Worth it - reusable pattern const cityLens = path('address.city') // Use everywhere: forms, APIs, state updates // ❌ Overkill - mutation is intended array.push(item) // Direct mutation is fine here // βœ… Worth it - immutability required const updated = over(itemsLens, (items) => [...items, item], state) ``` ## Lenses vs Other Patterns | Pattern | Composable | Type-Safe | Immutable | Readable | Reusable | | ------------------------- | ---------- | --------- | --------- | -------- | -------- | | **Lenses** (this library) | βœ… | βœ… | βœ… | βœ… | βœ… | | Spread operators | ❌ | ⚠️ | βœ… | ❌ | ❌ | | Immer | ⚠️ | βœ… | βœ… | βœ… | ⚠️ | | Lodash set/get | ⚠️ | ❌ | ❌ | βœ… | βœ… | | Ramda lenses | βœ… | ⚠️ | βœ… | ⚠️ | βœ… | **Why Receta Lenses?** - **Type-safe**: `prop`, `index` and `compose` are checked by the compiler - **Remeda integration**: Works seamlessly with `pipe()` - **Beginner-friendly**: No FP jargon, clear examples - **Practical**: Inspired by React, Redux, real apps - **Lightweight**: Tree-shakeable, minimal runtime ## What's Next? Now that you understand WHY lenses solve real problems, let's learn HOW to use them: 1. **[Constructors](./constructors.mdx)** - Creating lenses (`prop`, `path`, `index`, `lens`, `optional`) 2. **[Operations](./operations.mdx)** - Using lenses (`view`, `set`, `over`) 3. **[Composition](./composition.mdx)** - Combining lenses for deep access 4. **[Patterns](./patterns.mdx)** - Real-world recipes (React, Redux, Forms) 5. **[Migration](./migration.mdx)** - Refactoring from spread operators 6. **[API Reference](../reference/lens.mdx)** - Complete function reference Ready to start? Begin with **[Constructors β†’](./constructors.mdx)** --- # Lens Composition Lenses compose naturally, allowing you to build complex deep-access patterns from simple building blocks. ## The `compose()` Function **Purpose**: Combine two lenses to create a lens that focuses deeper. **Signature**: ```typescript title="Signature" function compose(outer: Lens, inner: Lens): Lens ``` **Mental Model**: `compose(outer, inner)` means "first apply outer, then inner" ## Basic Composition ```typescript import { prop, compose, view, set } from '@ontologyio/receta/lens' interface User { name: string address: Address } interface Address { street: string city: string } const addressLens = prop('address') const cityLens = prop('city') // Compose to access user's city const userCityLens = compose(addressLens, cityLens) const user = { name: 'Alice', address: { street: '123 Main', city: 'Boston' }, } view(userCityLens, user) // 'Boston' set(userCityLens, 'NYC', user) // => { name: 'Alice', address: { street: '123 Main', city: 'NYC' } } ``` ## Composition vs Path ```typescript // These are equivalent: // Using composition const cityLens1 = compose( prop('address'), prop('city') ) // Using path const cityLens2 = path('address.city') // Both work the same way view(cityLens1, user) === view(cityLens2, user) // true ``` **When to use each**: - Use `path()` for simple dot-notation access (more concise) - Use `compose()` when you need to build up lenses dynamically or reuse lens pieces ## Multi-Level Composition ```typescript interface State { user: { profile: { settings: { theme: string } } } } // Build up step by step const userLens = prop('user') const profileLens = prop('profile') const settingsLens = prop('settings') const themeLens = prop('theme') // Compose multiple levels const themeFromState = compose( compose(compose(userLens, profileLens), settingsLens), themeLens ) // Or use path for the same effect const themeFromState2 = path('user.profile.settings.theme') ``` ## Real-World: Shopping Cart Items ```typescript interface CartItem { id: string quantity: number } interface CartState { cart: { items: readonly CartItem[] } } const cartLens = prop('cart') const itemsLens = prop('items') // Access the first item const firstItemLens = compose( compose(cartLens, itemsLens), index(0) ) // Update first item quantity const state = { cart: { items: [{ id: '1', quantity: 2 }] } } over( firstItemLens, (item) => item && { ...item, quantity: item.quantity + 1 }, state ) // => { cart: { items: [{ id: '1', quantity: 3 }] } } ``` `index` may find nothing, so its focus is `CartItem | undefined`. A `prop` lens can't compose onto that, so the update handles the missing item itself. ## Real-World: Form Field Errors ```typescript interface FormState { fields: { email: { value: string; error?: string } password: { value: string; error?: string } } } const fieldsLens = prop('fields') const emailFieldLens = prop('email') const emailErrorLens = prop('error') // Compose to access email error const emailErrorFromForm = compose( compose(fieldsLens, emailFieldLens), emailErrorLens ) // Or simpler with path const emailErrorPath = path( 'fields.email.error' ) ``` ## Composition Laws Lens composition is associative: ```typescript // These are equivalent const lens1 = compose(compose(a, b), c) const lens2 = compose(a, compose(b, c)) // (a . b) . c = a . (b . c) view(lens1, data) === view(lens2, data) ``` ## Dynamic Composition ```typescript // Build lenses dynamically const buildFieldLens = (fieldName: K) => { const fieldsLens = prop('fields') const fieldLens = prop(fieldName) return compose(fieldsLens, fieldLens) } const emailLens = buildFieldLens('email') const passwordLens = buildFieldLens('password') ``` ## What's Next? - **[Patterns β†’](./patterns.mdx)** - Real-world recipes (React, Redux, Forms) - **[API Reference](../reference/lens.mdx)** - Complete function reference [← Back to Operations](./operations.mdx) --- # Lens Constructors Lens constructors create the "focusing glasses" that let you zoom into specific parts of your data. This guide covers all five ways to create lenses. ## Quick Reference | Constructor | Use For | Example | | ----------- | ------------------------------------ | ---------------------------------------- | | `prop` | Single object property | `prop('name')` | | `path` | Nested properties (dot notation) | `path('address.city')` | | `index` | Array element at specific position | `index(0)` | | `lens` | Custom logic (advanced) | `lens(getter, setter)` | | `optional` | Nullable/optional fields with Option | `optional(prop('email'))` | --- ## `prop()` - Property Lens **Purpose**: Focus on a single property of an object. **Signature**: ```typescript title="Signature" function prop(key: K): Lens ``` **When to use**: You need to access/update a direct property of an object. Pass both type arguments, the object and the key: TypeScript can't infer one and take the other. ### Basic Usage ```typescript import { prop, view, set, over } from '@ontologyio/receta/lens' interface User { name: string email: string age: number } const user: User = { name: 'Alice', email: 'alice@example.com', age: 30, } // Create lenses for each property const nameLens = prop('name') const emailLens = prop('email') const ageLens = prop('age') // Read view(nameLens, user) // 'Alice' // Write set(nameLens, 'Bob', user) // => { name: 'Bob', email: 'alice@example.com', age: 30 } // Transform over(ageLens, (age) => age + 1, user) // => { name: 'Alice', email: 'alice@example.com', age: 31 } ``` ### Real-World: React Form Field ```tsx import { useState } from 'react' interface FormState { username: string password: string rememberMe: boolean } const usernameLens = prop('username') const passwordLens = prop('password') const rememberMeLens = prop('rememberMe') // In React component const LoginForm = () => { const [form, setForm] = useState(initialForm) const updateField = (lens: Lens, value: A) => { setForm((state) => set(lens, value, state)) } return ( <> updateField(usernameLens, e.target.value)} /> updateField(passwordLens, e.target.value)} /> updateField(rememberMeLens, e.target.checked)} /> ) } ``` ### Real-World: Redux Reducer ```typescript interface AppState { counter: number theme: 'light' | 'dark' isLoading: boolean } const counterLens = prop('counter') const themeLens = prop('theme') const loadingLens = prop('isLoading') // Reducer with lenses - no nested spreads! const reducer = (state: AppState, action: Action): AppState => { switch (action.type) { case 'INCREMENT': return over(counterLens, (n) => n + 1, state) case 'TOGGLE_THEME': return over( themeLens, (theme) => (theme === 'light' ? 'dark' : 'light'), state ) case 'SET_LOADING': return set(loadingLens, action.payload, state) default: return state } } ``` --- ## `path()` - Nested Path Lens **Purpose**: Focus on deeply nested properties using dot notation. **Signature**: ```typescript title="Signature" function path(pathStr: string): Lens ``` **When to use**: You need to access/update properties multiple levels deep. Paths step through plain objects and arrays, so `items.0.name` reaches into the first item. The path string isn't checked against the type, and the value type you pass is taken on trust. ### Basic Usage ```typescript interface User { name: string address: { street: string city: string zip: string country: { name: string code: string } } } const user: User = { name: 'Alice', address: { street: '123 Main St', city: 'Boston', zip: '02101', country: { name: 'USA', code: 'US', }, }, } // Create path lenses const cityLens = path('address.city') const countryCodeLens = path('address.country.code') // Read nested values view(cityLens, user) // 'Boston' view(countryCodeLens, user) // 'US' // Update nested values - no spread hell! set(cityLens, 'NYC', user) // => { name: 'Alice', address: { ..., city: 'NYC', ... } } set(countryCodeLens, 'CA', user) // => { name: 'Alice', address: { ..., country: { name: 'USA', code: 'CA' } } } ``` ### Real-World: Stripe Settings ```typescript interface StripeSettings { account: { business: { name: string taxId: string address: { line1: string city: string postalCode: string country: string } } branding: { logo: string primaryColor: string icon: string } } paymentMethods: { card: { enabled: boolean } bankTransfer: { enabled: boolean } } } // Define lenses for deep paths const businessNameLens = path( 'account.business.name' ) const businessCityLens = path( 'account.business.address.city' ) const logoLens = path('account.branding.logo') const cardEnabledLens = path( 'paymentMethods.card.enabled' ) // Update multiple deep fields const updatedSettings = R.pipe( settings, set(businessNameLens, 'Acme Corp'), set(businessCityLens, 'San Francisco'), set(logoLens, 'https://cdn.example.com/logo.png'), set(cardEnabledLens, true) ) ``` ### Real-World: GitHub PR State ```typescript interface PRState { repository: { owner: string name: string } pullRequest: { number: number title: string author: { username: string avatarUrl: string } checks: { ci: { status: 'pending' | 'success' | 'failure' } lint: { status: 'pending' | 'success' | 'failure' } } } } // Path lenses for deep state const prTitleLens = path('pullRequest.title') const ciStatusLens = path('pullRequest.checks.ci.status') const lintStatusLens = path( 'pullRequest.checks.lint.status' ) // Update checks when webhook arrives const handleWebhook = (state: PRState, checkName: string, status: string) => { const lens = checkName === 'ci' ? ciStatusLens : lintStatusLens return set(lens, status, state) } ``` --- ## `index()` - Array Element Lens **Purpose**: Focus on a specific element in an array by index. **Signature**: ```typescript title="Signature" function index(idx: number): Lens ``` **When to use**: You need to update a specific array element immutably. The focus is `A | undefined`, because the index may be out of bounds. There, `view` returns `undefined` and `set` changes nothing. Setting `undefined` also changes nothing, so `index` can't remove an element. The lens works on `readonly A[]`, so declare an array field as `readonly` to compose `index` with `prop`. ### Basic Usage ```typescript import { index, view, set, over } from '@ontologyio/receta/lens' const numbers = [10, 20, 30, 40, 50] // Create index lenses const firstLens = index(0) const thirdLens = index(2) // Read element view(firstLens, numbers) // 10 view(thirdLens, numbers) // 30 // Update element set(firstLens, 100, numbers) // => [100, 20, 30, 40, 50] set(thirdLens, 300, numbers) // => [10, 20, 300, 40, 50] // Transform element (n is undefined when the index is out of bounds) over(firstLens, (n) => (n ?? 0) * 2, numbers) // => [20, 20, 30, 40, 50] ``` ### Real-World: Todo List ```typescript interface Todo { id: string text: string done: boolean priority: 'low' | 'medium' | 'high' } interface TodoListState { todos: readonly Todo[] } const todosLens = prop('todos') const firstTodoLens = index(0) const secondTodoLens = index(1) // Mark first todo as done const markFirstDone = (state: TodoListState) => { // Compose todosLens with firstTodoLens const firstTodo = compose(todosLens, firstTodoLens) return over( firstTodo, (todo) => (todo ? { ...todo, done: true } : todo), state ) } // Update priority of second todo const updateSecondPriority = (state: TodoListState, priority: Todo['priority']) => { const secondTodo = compose(todosLens, secondTodoLens) return over( secondTodo, (todo) => (todo ? { ...todo, priority } : todo), state ) } ``` ### Real-World: Shopping Cart ```typescript interface CartItem { productId: string name: string quantity: number price: number } interface Cart { items: readonly CartItem[] total: number } const itemsLens = prop('items') // Update quantity of specific cart item const updateItemQuantity = ( cart: Cart, itemIndex: number, newQuantity: number ): Cart => { const itemLens = index(itemIndex) const itemsWithIndex = compose(itemsLens, itemLens) return over( itemsWithIndex, (item) => (item ? { ...item, quantity: newQuantity } : item), cart ) } // Remove item (setting undefined changes nothing, so filter instead) const removeItem = (cart: Cart, itemIndex: number): Cart => { return over( itemsLens, (items) => items.filter((_, idx) => idx !== itemIndex), cart ) } ``` --- ## `lens()` - Custom Lens **Purpose**: Create a lens with custom get/set logic for complex scenarios. **Signature**: ```typescript title="Signature" function lens( get: (source: S) => A, set: (value: A) => (source: S) => S ): Lens ``` **When to use**: Built-in constructors don't fit your needs (rare). ### Basic Usage ```typescript import { lens, view, set, over } from '@ontologyio/receta/lens' interface User { firstName: string lastName: string } // Custom lens for full name const fullNameLens = lens( // Getter: combine first + last (user) => `${user.firstName} ${user.lastName}`, // Setter: split and update both fields (fullName) => (user) => { const [firstName, ...rest] = fullName.split(' ') const lastName = rest.join(' ') return { ...user, firstName, lastName: lastName || user.lastName } } ) const user = { firstName: 'Alice', lastName: 'Smith' } view(fullNameLens, user) // 'Alice Smith' set(fullNameLens, 'Bob Johnson', user) // => { firstName: 'Bob', lastName: 'Johnson' } ``` ### Real-World: Temperature Conversion ```typescript interface WeatherData { temperatureCelsius: number humidity: number pressure: number } // Lens that reads/writes in Fahrenheit, stores in Celsius const temperatureFahrenheitLens = lens( // Get: C to F (data) => (data.temperatureCelsius * 9) / 5 + 32, // Set: F to C (fahrenheit) => (data) => ({ ...data, temperatureCelsius: ((fahrenheit - 32) * 5) / 9, }) ) const weather = { temperatureCelsius: 20, humidity: 65, pressure: 1013 } view(temperatureFahrenheitLens, weather) // 68 set(temperatureFahrenheitLens, 77, weather) // => { temperatureCelsius: 25, humidity: 65, pressure: 1013 } ``` ### Real-World: Computed Total ```typescript interface Invoice { subtotal: number taxRate: number total: number // computed from subtotal + tax } // Lens for subtotal that auto-updates total const subtotalLens = lens( (invoice) => invoice.subtotal, (newSubtotal) => (invoice) => ({ ...invoice, subtotal: newSubtotal, total: newSubtotal * (1 + invoice.taxRate), }) ) const invoice = { subtotal: 100, taxRate: 0.1, total: 110 } set(subtotalLens, 200, invoice) // => { subtotal: 200, taxRate: 0.1, total: 220 } ``` --- ## `optional()` - Optional Value Lens **Purpose**: Handle nullable/optional fields safely with Option type. **Signature**: ```typescript title="Signature" function optional(baseLens: Lens): Lens> ``` **When to use**: Working with optional/nullable fields using the Option pattern. ### Basic Usage ```typescript import { prop, optional, view, set, over } from '@ontologyio/receta/lens' import { some, none, isSome, map as mapOption } from '@ontologyio/receta/option' interface User { name: string email?: string phone?: string } const emailLens = prop('email') const optionalEmailLens = optional(emailLens) const user1 = { name: 'Alice', email: 'alice@example.com' } const user2 = { name: 'Bob' } // View returns Option view(optionalEmailLens, user1) // Some('alice@example.com') view(optionalEmailLens, user2) // None // Set with Some/None set(optionalEmailLens, some('newemail@example.com'), user2) // => { name: 'Bob', email: 'newemail@example.com' } set(optionalEmailLens, none(), user1) // => { name: 'Alice', email: undefined } // Transform with Option operations over( optionalEmailLens, (opt) => mapOption(opt, (email) => email.toLowerCase()), user1 ) // => { name: 'Alice', email: 'alice@example.com' } ``` ### Real-World: Profile Bio ```typescript interface Profile { username: string bio?: string website?: string location?: string } const bioLens = prop('bio') const optionalBioLens = optional(bioLens) // Provide default bio if missing const ensureBio = (profile: Profile): Profile => { return over( optionalBioLens, (opt) => (isSome(opt) ? opt : some('No bio provided')), profile ) } // Truncate long bios const truncateBio = (profile: Profile, maxLength: number): Profile => { return over( optionalBioLens, (opt) => mapOption(opt, (bio) => bio.length > maxLength ? bio.slice(0, maxLength) + '...' : bio ), profile ) } ``` --- ## Choosing the Right Constructor ### Decision Tree ```text Do you need custom logic (e.g., computed values)? β”œβ”€ Yes β†’ use lens() └─ No β”œβ”€ Single property? β”‚ β”œβ”€ Required β†’ use prop() β”‚ └─ Optional β†’ use prop() + optional() β”‚ β”œβ”€ Nested property? β”‚ └─ use path() β”‚ └─ Array element? └─ use index() ``` ### Examples ```typescript // βœ… prop - single required field const nameLens = prop('name') // βœ… path - nested field const cityLens = path('address.city') // βœ… index - array element const firstLens = index(0) // βœ… prop + optional - nullable field const emailLens = optional(prop('email')) // βœ… lens - custom logic needed const fullNameLens = lens( (user) => `${user.firstName} ${user.lastName}`, (name) => (user) => ({ ...user, ...splitName(name) }) ) ``` ## Comparison Table | Constructor | Type Safety | Nested Support | Custom Logic | Nullable Support | | ----------- | ----------- | -------------- | ------------ | ---------------- | | `prop` | βœ… Full | ❌ | ❌ | ⚠️ Manual | | `path` | ⚠️ Runtime | βœ… | ❌ | ⚠️ Manual | | `index` | βœ… Full | ❌ | ❌ | βœ… Returns `A?` | | `lens` | βœ… Full | βœ… | βœ… | βœ… Custom | | `optional` | βœ… Full | ❌ | ❌ | βœ… Option | ## What's Next? Now that you know how to create lenses, learn how to use them: - **[Operations β†’](./operations.mdx)** - view, set, over - **[Composition](./composition.mdx)** - Combining lenses for deep access - **[Patterns](./patterns.mdx)** - Real-world recipes **Quick links**: - [← Back to Overview](./index.mdx) - [API Reference](../reference/lens.mdx) --- # Migration Guide Step-by-step guide for refactoring existing code to use lenses. ## Why Migrate? **Before (Spread Hell)**: ```typescript const updateCity = (state: State) => ({ ...state, user: { ...state.user, address: { ...state.user.address, city: 'NYC', }, }, }) ``` **After (Clean Lenses)**: ```typescript const cityLens = path('user.address.city') const updateCity = (state: State) => set(cityLens, 'NYC', state) ``` ## Migration Steps ### Step 1: Identify Repeated Update Patterns Look for code that updates the same nested paths multiple times: ```typescript // ❌ Before: Repeated pattern const updateUserCity = (state) => ({ ...state, user: { ...state.user, address: { ...state.user.address, city: newCity } }, }) const updateUserZip = (state) => ({ ...state, user: { ...state.user, address: { ...state.user.address, zip: newZip } }, }) // βœ… After: Define lens once, reuse everywhere const cityLens = path('user.address.city') const zipLens = path('user.address.zip') const updateUserCity = (state, city) => set(cityLens, city, state) const updateUserZip = (state, zip) => set(zipLens, zip, state) ``` ### Step 2: Replace Simple Property Updates ```typescript // ❌ Before const updateName = (user: User, name: string) => ({ ...user, name, }) // βœ… After const nameLens = prop('name') const updateName = (user: User, name: string) => set(nameLens, name, user) ``` ### Step 3: Replace Nested Updates ```typescript // ❌ Before const updateAddress = (state: State) => ({ ...state, user: { ...state.user, address: { ...state.user.address, city: 'NYC', zip: '10001', }, }, }) // βœ… After const cityLens = path('user.address.city') const zipLens = path('user.address.zip') const updateAddress = (state: State) => R.pipe( state, set(cityLens, 'NYC'), set(zipLens, '10001') ) ``` ### Step 4: Replace Transformations ```typescript // ❌ Before const incrementCounter = (state: State) => ({ ...state, counter: state.counter + 1, }) // βœ… After const counterLens = prop('counter') const incrementCounter = (state: State) => over(counterLens, (n) => n + 1, state) ``` ### Step 5: Replace Array Updates ```typescript // ❌ Before const updateFirstTodo = (state: TodoState) => ({ ...state, todos: state.todos.map((todo, idx) => idx === 0 ? { ...todo, done: true } : todo ), }) // βœ… After const todosLens = prop('todos') const updateFirstTodo = (state: TodoState) => over(todosLens, (todos) => todos.map((todo, idx) => (idx === 0 ? { ...todo, done: true } : todo)) , state) ``` ## Migration Checklist - [ ] Find all nested spread operators (3+ levels) - [ ] Identify repeated update patterns - [ ] Create lenses for commonly accessed paths - [ ] Replace `set` operations with `set(lens, value)` - [ ] Replace transformations with `over(lens, fn)` - [ ] Chain multiple updates with `pipe` - [ ] Update tests to verify immutability - [ ] Remove helper functions that manually spread ## Incremental Migration You don't have to migrate everything at once: ```typescript // βœ… Mix and match during transition const partialMigration = (state: State) => { // Use lenses for deep paths const withCity = set(cityLens, 'NYC', state) // Keep spreads for shallow updates (if preferred) return { ...withCity, timestamp: Date.now(), } } ``` ## Common Pitfalls ### Pitfall 1: Forgetting to Define Lenses ```typescript // ❌ Bad: Creating lenses inline repeatedly setState((s) => set(prop('counter'), 10, s)) setState((s) => set(prop('counter'), 20, s)) // βœ… Good: Define once, reuse const counterLens = prop('counter') setState((s) => set(counterLens, 10, s)) setState((s) => set(counterLens, 20, s)) ``` ### Pitfall 2: Using `set` When You Need `over` ```typescript // ❌ Bad: Reading then setting const increment = (s) => set(counterLens, view(counterLens, s) + 1, s) // βœ… Good: Using over const increment = (s) => over(counterLens, (n) => n + 1, s) ``` ## What's Next? - **[API Reference β†’](../reference/lens.mdx)** - Complete function signatures - **[Patterns](./patterns.mdx)** - More real-world recipes [← Back to Patterns](./patterns.mdx) --- # Lens Operations Once you have a lens, you perform three fundamental operations: view (read), set (write), and over (transform). ## Quick Reference | Operation | Purpose | Signature | | --------- | ----------------------- | ------------------------------------------------ | | `view` | Read value through lens | `view(lens, source): A` | | `set` | Update value | `set(lens, value, source): S` | | `over` | Transform value | `over(lens, fn: (a: A) => A, source): S` | --- ## `view()` - Reading Values **Purpose**: Extract the focused value from your data structure. **Signatures**: ```typescript title="Signature" // Data-first function view(lens: Lens, source: S): A // Data-last (for pipes) function view(lens: Lens): (source: S) => A ``` ### Basic Usage ```typescript import { prop, path, view } from '@ontologyio/receta/lens' import * as R from 'remeda' interface User { name: string address: { city: string } } const user = { name: 'Alice', address: { city: 'Boston' } } // Data-first const name = view(prop('name'), user) // 'Alice' const city = view(path('address.city'), user) // 'Boston' // Data-last (in pipe) const result = R.pipe( user, view(prop('name')) ) // 'Alice' ``` ### Real-World: API Response Parsing ```typescript interface GitHubPR { data: { repository: { pullRequest: { title: string author: { login: string } reviewDecision: string | null } } } } const titleLens = path('data.repository.pullRequest.title') const authorLens = path('data.repository.pullRequest.author.login') const parsePR = (response: GitHubPR) => ({ title: view(titleLens, response), author: view(authorLens, response), }) ``` --- ## `set()` - Updating Values **Purpose**: Update the focused value immutably. **Signatures**: ```typescript title="Signature" // Data-first function set(lens: Lens, value: A, source: S): S // Data-last (for pipes) function set(lens: Lens, value: A): (source: S) => S ``` ### Basic Usage ```typescript const user = { name: 'Alice', age: 30 } // Data-first const updated = set(prop('name'), 'Bob', user) // => { name: 'Bob', age: 30 } // Original unchanged console.log(user.name) // 'Alice' // Data-last (in pipe) const result = R.pipe( user, set(prop('name'), 'Charlie'), set(prop('age'), 31) ) // => { name: 'Charlie', age: 31 } ``` ### Real-World: React State Update ```typescript interface AppState { user: { name: string; email: string } settings: { theme: 'light' | 'dark' } } const themeLens = path('settings.theme') const emailLens = path('user.email') // In React component const updateTheme = () => { setState((state) => set(themeLens, 'dark', state)) } const updateEmail = (newEmail: string) => { setState((state) => set(emailLens, newEmail, state)) } // Multiple updates in sequence const updateMultiple = () => { setState((state) => R.pipe( state, set(themeLens, 'dark'), set(emailLens, 'newemail@example.com') ) ) } ``` ### Real-World: Form Validation Result ```typescript interface Form { fields: { email: { value: string; error?: string } password: { value: string; error?: string } } isValid: boolean } const emailErrorLens = path('fields.email.error') const passwordErrorLens = path('fields.password.error') const isValidLens = prop('isValid') const validateAndUpdate = (form: Form): Form => { const emailError = validateEmail(form.fields.email.value) const passwordError = validatePassword(form.fields.password.value) return R.pipe( form, set(emailErrorLens, emailError), set(passwordErrorLens, passwordError), set(isValidLens, !emailError && !passwordError) ) } ``` --- ## `over()` - Transforming Values **Purpose**: Read the current value, transform it, and set the result. **Signatures**: ```typescript title="Signature" // Data-first function over(lens: Lens, fn: (a: A) => A, source: S): S // Data-last (for pipes) function over(lens: Lens, fn: (a: A) => A): (source: S) => S ``` ### Basic Usage ```typescript const user = { name: 'alice', age: 30 } // Data-first const older = over(prop('age'), (age) => age + 1, user) // => { name: 'alice', age: 31 } const caps = over(prop('name'), (name) => name.toUpperCase(), user) // => { name: 'ALICE', age: 30 } // Data-last (in pipe) const result = R.pipe( user, over(prop('name'), (name) => name.toUpperCase()), over(prop('age'), (age) => age + 1) ) // => { name: 'ALICE', age: 31 } ``` ### Real-World: Counter Increment ```typescript interface State { counter: number history: number[] } const counterLens = prop('counter') const historyLens = prop('history') // Increment and record const increment = (state: State): State => R.pipe( state, over(counterLens, (n) => n + 1), over(historyLens, (history) => [...history, state.counter]) ) ``` ### Real-World: Shopping Cart Quantity ```typescript interface Cart { items: Array<{ id: string; quantity: number; price: number }> total: number } const itemsLens = prop('items') const totalLens = prop('total') // Update quantity for specific item const updateQuantity = (cart: Cart, itemId: string, delta: number): Cart => { return R.pipe( cart, over(itemsLens, (items) => items.map((item) => item.id === itemId ? { ...item, quantity: item.quantity + delta } : item ) ), // Recalculate total from the updated items (updated) => set( totalLens, updated.items.reduce((sum, item) => sum + item.quantity * item.price, 0), updated ) ) } ``` ### Real-World: Text Normalization ```typescript interface Article { title: string content: string tags: string[] } const titleLens = prop('title') const contentLens = prop('content') const tagsLens = prop('tags') // Normalize article text const normalizeArticle = (article: Article): Article => R.pipe( article, over(titleLens, (title) => title.trim()), over(contentLens, (content) => content.trim().replace(/\s+/g, ' ')), over(tagsLens, (tags) => tags.map((tag) => tag.toLowerCase().trim())) ) ``` --- ## Operation Patterns ### Pattern 1: Conditional Updates ```typescript // Update only if condition is met const updateIfPositive = (state: State, value: number): State => { return value > 0 ? set(counterLens, value, state) : state } // Transform conditionally const incrementIfEven = (state: State): State => { return over( counterLens, (n) => (n % 2 === 0 ? n + 1 : n), state ) } ``` ### Pattern 2: Chained Transformations ```typescript // Multiple transformations in sequence const processUser = (user: User): User => R.pipe( user, over(prop('name'), (name) => name.trim()), over(prop('name'), (name) => name.toLowerCase()), over(prop('email'), (email) => email.toLowerCase()), set(prop('updatedAt'), Date.now()) ) ``` ### Pattern 3: Accumulating Results ```typescript interface Stats { count: number sum: number average: number } const countLens = prop('count') const sumLens = prop('sum') const avgLens = prop('average') const addValue = (stats: Stats, value: number): Stats => R.pipe( stats, over(countLens, (n) => n + 1), over(sumLens, (sum) => sum + value), over(avgLens, () => (stats.sum + value) / (stats.count + 1)) ) ``` --- ## Equivalence: over = view + transform + set Understanding that `over` is just a convenience for view β†’ transform β†’ set: ```typescript // These are equivalent: // Using over const result1 = over(ageLens, (age) => age + 1, user) // Using view + set const currentAge = view(ageLens, user) const newAge = currentAge + 1 const result2 = set(ageLens, newAge, user) // result1 and result2 hold the same data, in two separate objects ``` When to use each: - Use `over` when you need the current value to compute the new value - Use `set` when you already know the new value - Use `view` when you only need to read --- ## Data-First vs Data-Last All operations support both calling conventions: ```typescript // Data-first: All arguments at once view(someLens, data) set(someLens, value, data) over(someLens, fn, data) // Data-last: Partial application for pipes view(someLens)(data) set(someLens, value)(data) over(someLens, fn)(data) // In practice with pipes R.pipe( data, view(someLens), // returns the focused value // ... more transformations ) R.pipe( data, set(someLens, value), // returns updated data set(otherLens, otherValue), // chain updates ) R.pipe( data, over(someLens, fn), // returns transformed data over(otherLens, otherFn), // chain transformations ) ``` --- ## Common Mistakes ### Mistake 1: Forgetting Immutability ```typescript // ❌ Wrong - mutating directly const wrong = (user: User) => { user.name = 'Bob' // MUTATION! return user } // βœ… Correct - using lens const correct = (user: User) => set(nameLens, 'Bob', user) ``` ### Mistake 2: Using `set` Instead of `over` ```typescript // ❌ Wordy - reading, then setting const increment = (state: State) => { const current = view(counterLens, state) return set(counterLens, current + 1, state) } // βœ… Correct - using over const increment = (state: State) => over(counterLens, (n) => n + 1, state) ``` ### Mistake 3: Wrong Pipe Order ```typescript // ❌ Wrong - view returns the value, not the source R.pipe( user, view(nameLens), // returns string set(ageLens, 31) // ERROR: expects User, got string ) // βœ… Correct - separate pipes or use only set/over R.pipe( user, set(nameLens, 'Bob'), set(ageLens, 31) ) // Or extract value separately const name = view(nameLens, user) const updated = set(ageLens, 31, user) ``` --- ## Performance Considerations ### Lens Creation is Cheap ```typescript // βœ… Fine to create inline setState((state) => set(prop('counter'), 10, state)) // βœ… Also fine to cache const counterLens = prop('counter') setState((state) => set(counterLens, 10, state)) ``` ### Multiple Updates ```typescript // βœ… Reads top to bottom R.pipe( state, set(lens1, value1), set(lens2, value2), set(lens3, value3) ) // ⚠️ The same work, and the same copies, with more noise let result = state result = set(lens1, value1, result) result = set(lens2, value2, result) result = set(lens3, value3, result) ``` --- ## What's Next? - **[Composition β†’](./composition.mdx)** - Combining lenses for deep access - **[Patterns](./patterns.mdx)** - Real-world recipes (React, Redux, Forms) - **[API Reference](../reference/lens.mdx)** - Complete function signatures **Quick links**: - [← Back to Constructors](./constructors.mdx) - [← Back to Overview](./index.mdx) --- # Common Patterns & Real-World Recipes Copy-paste-ready patterns for common scenarios. ## React State Management ### Pattern: Form State with Lenses ```tsx import { useState } from 'react' import { prop, path, set, type Lens } from '@ontologyio/receta/lens' interface FormState { user: { firstName: string lastName: string email: string } billing: { cardNumber: string expiryDate: string } agreedToTerms: boolean } const firstNameLens = path('user.firstName') const lastNameLens = path('user.lastName') const emailLens = path('user.email') const cardNumberLens = path('billing.cardNumber') const termsLens = prop('agreedToTerms') export const CheckoutForm = () => { const [form, setForm] = useState(initialForm) const updateField = (lens: Lens, value: A) => { setForm((state) => set(lens, value, state)) } return (
updateField(firstNameLens, e.target.value)} /> updateField(emailLens, e.target.value)} /> updateField(cardNumberLens, e.target.value)} /> updateField(termsLens, e.target.checked)} />
) } ``` ### Pattern: Zustand Store ```typescript import { create } from 'zustand' import { prop, path, set, over } from '@ontologyio/receta/lens' interface Store { user: { name: string; email: string } settings: { theme: 'light' | 'dark'; notifications: boolean } counter: number } const themeLens = path('settings.theme') const counterLens = prop('counter') // Zustand's own setter is named setState so it doesn't hide the lens `set` const useStore = create((setState) => ({ user: { name: '', email: '' }, settings: { theme: 'light', notifications: true }, counter: 0, updateTheme: (theme) => setState((state) => set(themeLens, theme, state)), increment: () => setState((state) => over(counterLens, (n) => n + 1, state)), })) ``` ## Redux Patterns ### Pattern: Reducer with Lenses ```typescript import { prop, path, set, over } from '@ontologyio/receta/lens' import * as R from 'remeda' interface AppState { user: { isAuthenticated: boolean; profile: UserProfile } ui: { isLoading: boolean; modal: { isOpen: boolean } } data: { items: Item[] } } const isAuthenticatedLens = path('user.isAuthenticated') const isLoadingLens = path('ui.isLoading') const modalOpenLens = path('ui.modal.isOpen') const itemsLens = path('data.items') const reducer = (state: AppState, action: Action): AppState => { switch (action.type) { case 'LOGIN_SUCCESS': return R.pipe( state, set(isAuthenticatedLens, true), set(isLoadingLens, false) ) case 'TOGGLE_MODAL': return over(modalOpenLens, (open) => !open, state) case 'ADD_ITEM': return over(itemsLens, (items) => [...items, action.payload], state) default: return state } } ``` ## Form Validation ### Pattern: Field Errors ```typescript interface FormField { value: T error?: string touched: boolean } interface Form { fields: { email: FormField password: FormField } isValid: boolean } const emailValueLens = path('fields.email.value') const emailErrorLens = path('fields.email.error') const emailTouchedLens = path('fields.email.touched') const validateEmail = (form: Form): Form => { const email = view(emailValueLens, form) const error = email.includes('@') ? undefined : 'Invalid email' return R.pipe( form, set(emailErrorLens, error), set(emailTouchedLens, true), set(prop('isValid'), error === undefined) ) } ``` ## API Response Transformation ### Pattern: Normalizing Nested Data ```typescript interface APIResponse { data: { user: { profile: { displayName: string bio: string social: { twitter?: string github?: string } } } } } const displayNameLens = path( 'data.user.profile.displayName' ) const bioLens = path('data.user.profile.bio') const twitterLens = path( 'data.user.profile.social.twitter' ) const normalizeResponse = (response: APIResponse): APIResponse => R.pipe( response, over(displayNameLens, (name) => name.trim()), over(bioLens, (bio) => bio.slice(0, 160)), over(twitterLens, (handle) => handle === undefined || handle.startsWith('@') ? handle : `@${handle}` ) ) ``` ## Configuration Management ### Pattern: Environment-Specific Config ```typescript interface Config { api: { baseUrl: string timeout: number retry: { maxAttempts: number; delay: number } } features: { analytics: boolean darkMode: boolean } } const baseUrlLens = path('api.baseUrl') const timeoutLens = path('api.timeout') const maxAttemptsLens = path('api.retry.maxAttempts') const analyticsLens = path('features.analytics') const toProduction = (config: Config): Config => R.pipe( config, set(baseUrlLens, 'https://api.production.com'), set(timeoutLens, 5000), set(maxAttemptsLens, 5), set(analyticsLens, true) ) const toDevelopment = (config: Config): Config => R.pipe( config, set(baseUrlLens, 'http://localhost:3000'), set(timeoutLens, 30000), set(analyticsLens, false) ) ``` ## List Operations ### Pattern: Update Specific Item ```typescript interface TodoList { todos: Array<{ id: string; text: string; done: boolean }> } const todosLens = prop('todos') const toggleTodo = (state: TodoList, todoId: string): TodoList => { return over(todosLens, (todos) => todos.map((todo) => todo.id === todoId ? { ...todo, done: !todo.done } : todo ), state ) } const removeTodo = (state: TodoList, todoId: string): TodoList => { return over(todosLens, (todos) => todos.filter((todo) => todo.id !== todoId), state ) } ``` ## What's Next? - **[Migration Guide β†’](./migration.mdx)** - Refactoring from spread operators - **[API Reference](../reference/lens.mdx)** - Complete function reference [← Back to Composition](./composition.mdx) --- # Overview > Learn the basics of function memoization in 10 minutes. ## Table of Contents 1. [Why Memoization?](#why-memoization) 2. [Installation](#installation) 3. [Your First Memoized Function](#your-first-memoized-function) 4. [Common Use Cases](#common-use-cases) 5. [Next Steps](#next-steps) --- ## Why Memoization? ### The Problem: Repeated Expensive Work ```typescript // Expensive computation function fibonacci(n: number): number { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) } fibonacci(40) // Takes ~1 second fibonacci(40) // Takes ~1 second again! (wasteful) fibonacci(40) // Takes ~1 second again! (wasteful) ``` **Problem**: Same input, same output, but we're recomputing every time. ### The Solution: Memoization ```typescript import { memoize } from '@ontologyio/receta/memo' const memoFib = memoize(fibonacci) memoFib(40) // Takes ~1 second (computed) memoFib(40) // Takes <1ms (cached!) memoFib(40) // Takes <1ms (cached!) ``` **Benefit**: 1000x+ speedup for cached calls. --- ## Installation Receta is built on Remeda and installs it as a dependency. Add `remeda` yourself if your own code imports it: ```bash # Using bun (recommended) bun add @ontologyio/receta remeda # Using npm npm install @ontologyio/receta remeda # Using yarn yarn add @ontologyio/receta remeda # Using pnpm pnpm add @ontologyio/receta remeda ``` **TypeScript Configuration** Receta requires TypeScript 5.0+ with strict mode: ```json { "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler" } } ``` --- ## Your First Memoized Function ### Step 1: Import ```typescript import { memoize } from '@ontologyio/receta/memo' ``` ### Step 2: Wrap Your Function ```typescript // Original function function getUserById(id: string): User { console.log(`Fetching user ${id}`) return database.users.find(u => u.id === id)! } // Memoized version const memoGetUserById = memoize(getUserById) ``` ### Step 3: Use It ```typescript const user1 = memoGetUserById('123') // Console: "Fetching user 123" const user2 = memoGetUserById('123') // (no console output - cached!) console.log(user1 === user2) // true (same object) ``` ### How It Works 1. **First call**: Function executes, result cached with argument as key 2. **Subsequent calls**: If argument matches, return cached result 3. **Different argument**: Execute function, cache new result --- ## Common Use Cases ### 1. Expensive Computations ```typescript import { memoize } from '@ontologyio/receta/memo' const factorial = memoize((n: number): number => { if (n <= 1) return 1 return n * factorial(n - 1) }) factorial(100) // Computed factorial(100) // Cached (instant) ``` **When to use**: Mathematical computations, parsing, transformations ### 2. API Calls ```typescript import { memoizeAsync, ttlCache } from '@ontologyio/receta/memo' const fetchUser = memoizeAsync( async (id: string) => { const res = await fetch(`/api/users/${id}`) return res.json() }, { cache: ttlCache(5 * 60 * 1000) } // 5 min cache ) await fetchUser('123') // API call await fetchUser('123') // Cached (no network!) ``` **When to use**: API responses, external data, slow network calls ### 3. Database Queries ```typescript import { memoizeAsync, lruCache } from '@ontologyio/receta/memo' const queryUsers = memoizeAsync( async (filter: string) => { return await db.query(`SELECT * FROM users WHERE ${filter}`) }, { keyFn: (filter) => filter, cache: lruCache(50) // Keep last 50 queries } ) await queryUsers('active = true') // DB query await queryUsers('active = true') // Cached ``` **When to use**: Read-heavy queries, configuration data, lookups ### 4. Image Processing ```typescript import { memoizeBy, lruCache } from '@ontologyio/receta/memo' interface ImageOptions { url: string width: number height: number } const resizeImage = memoizeBy( (opts: ImageOptions) => processImage(opts), (opts) => `${opts.url}:${opts.width}x${opts.height}`, { cache: lruCache(100) } ) resizeImage({ url: 'photo.jpg', width: 800, height: 600 }) // Processed resizeImage({ url: 'photo.jpg', width: 800, height: 600 }) // Cached ``` **When to use**: Image/video transformations, file processing --- ## Quick Comparison ### Without Memoization ```typescript // ❌ Problems: // - Repeated work // - Slow performance // - Wasted resources // - No request deduplication async function getWeather(city: string) { const res = await fetch(`/api/weather/${city}`) return res.json() } // Multiple components requesting same data const [a, b, c] = await Promise.all([ getWeather('NYC'), // API call getWeather('NYC'), // API call (duplicate!) getWeather('NYC') // API call (duplicate!) ]) // 3 API calls for same data! ``` ### With Memoization ```typescript // βœ… Benefits: // - Work done once // - Fast subsequent calls // - Efficient resource use // - Request deduplication import { memoizeAsync } from '@ontologyio/receta/memo' const getWeather = memoizeAsync(async (city: string) => { const res = await fetch(`/api/weather/${city}`) return res.json() }) const [a, b, c] = await Promise.all([ getWeather('NYC'), // API call getWeather('NYC'), // Deduplicated! getWeather('NYC') // Deduplicated! ]) // Only 1 API call! ``` --- ## Understanding Cache Keys ### Single Argument (Automatic) ```typescript import { memoize } from '@ontologyio/receta/memo' // Cache key = first argument const double = memoize((n: number) => n * 2) double(5) // Cache key: 5 double(5) // Cache hit! ``` ### Multiple Arguments (Manual Key) ```typescript import { memoizeBy } from '@ontologyio/receta/memo' // Custom key function const add = memoizeBy( (a: number, b: number) => a + b, (a, b) => `${a},${b}` // Cache key ) add(2, 3) // Cache key: "2,3" add(2, 3) // Cache hit! add(3, 2) // Cache key: "3,2" (different key, computed) ``` ### Object Arguments (Serialize) ```typescript import { memoizeBy } from '@ontologyio/receta/memo' interface Options { id: string type: string } const fetchData = memoizeBy( (opts: Options) => api.fetch(opts), (opts) => JSON.stringify(opts) // Serialize to key ) fetchData({ id: '1', type: 'user' }) // Computed fetchData({ id: '1', type: 'user' }) // Cached ``` --- ## Cache Invalidation ### Manual Clearing ```typescript import { memoize, clearCache } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser) getUser('123') // Computed getUser('123') // Cached // Clear cache when data changes clearCache(getUser) getUser('123') // Recomputed ``` ### Alternative Syntax ```typescript const getUser = memoize(fetchUser) getUser('123') // Computed getUser('123') // Cached // Using .clear() method getUser.clear() getUser('123') // Recomputed ``` ### Time-Based Expiration ```typescript import { memoize, ttlCache } from '@ontologyio/receta/memo' const getWeather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) // 5 min TTL }) getWeather('NYC') // Computed getWeather('NYC') // Cached // After 5 minutes... getWeather('NYC') // Recomputed (expired) ``` --- ## Performance Tips ### βœ… Good Candidates for Memoization - **Pure functions** (same input β†’ same output) - **Expensive computations** (parsing, calculations) - **API calls** (network latency) - **Database queries** (I/O bound) - **Recursive algorithms** (fibonacci, factorial) ### ❌ Poor Candidates - **Functions with side effects** (logging, I/O) - **Random outputs** (Math.random, Date.now) - **Always-unique arguments** (no cache hits) - **Memory-intensive results** (large objects) - **Real-time data** (stock prices, live feeds) --- ## Common Pitfalls ### Pitfall 1: Memoizing Impure Functions ```typescript // ❌ Bad: Side effects const log = memoize((msg: string) => { console.log(msg) // Side effect! return msg }) log('hello') // Logs "hello" log('hello') // Cached - doesn't log! (unexpected) ``` ### Pitfall 2: Forgetting Multi-Arg Keys ```typescript // ❌ Bad: Only uses first argument const add = memoize((a: number, b: number) => a + b) add(2, 3) // 5, cache key: 2 add(2, 5) // 5 (WRONG! cached from key 2) // βœ… Good: Custom key function const add = memoizeBy( (a: number, b: number) => a + b, (a, b) => `${a},${b}` ) ``` ### Pitfall 3: Unbounded Cache Growth ```typescript // ❌ Bad: Unbounded cache const process = memoize(expensiveProcess) for (let i = 0; i < 1000000; i++) { process(i) // Cache grows to 1M entries! (OOM) } // βœ… Good: Use LRU cache import { lruCache } from '@ontologyio/receta/memo' const process = memoize(expensiveProcess, { cache: lruCache(1000) // Max 1000 entries }) ``` --- ## Next Steps Now that you understand the basics: 1. **Learn cache strategies** β†’ [Core Concepts](./core-concepts.mdx) 2. **See real-world examples** β†’ [Practical Examples](./practical-examples.mdx) 3. **Understand TypeScript types** β†’ [TypeScript Integration](./typescript-integration.mdx) 4. **Handle errors properly** β†’ [Error Handling](./error-handling.mdx) ### Quick Reference ```typescript import { memoize, // Single-arg caching memoizeBy, // Custom key extraction memoizeAsync, // Async + deduplication ttlCache, // Time-based expiration lruCache, // Memory-limited weakCache, // GC-friendly clearCache // Invalidate cache } from '@ontologyio/receta/memo' // Basic const fast = memoize(slow) // Multi-arg const add = memoizeBy((a: number, b: number) => a + b, (a, b) => `${a},${b}`) // Async const fetch = memoizeAsync(async (id: string) => await api.get(id)) // TTL const weather = memoize(getWeather, { cache: ttlCache(60000) }) // LRU const user = memoize(getUser, { cache: lruCache(100) }) ``` --- # Core Concepts > Deep dive into memoization functions and cache strategies. ## Table of Contents 1. [memoize()](#memoize) 2. [memoizeBy()](#memoizeby) 3. [memoizeAsync()](#memoizeasync) 4. [Cache Strategies](#cache-strategies) 5. [Cache Interface](#cache-interface) --- ## memoize() Basic memoization for single-argument functions. ### Signature ```typescript function memoize( fn: (...args: Args) => R, options?: MemoizeOptions ): MemoizedFunction ``` ### How It Works ```typescript import { memoize } from '@ontologyio/receta/memo' const expensive = (n: number) => { console.log(`Computing ${n}`) return n * 2 } const memoExpensive = memoize(expensive) memoExpensive(5) // Console: "Computing 5", returns 10 memoExpensive(5) // (no console output), returns 10 (cached) memoExpensive(10) // Console: "Computing 10", returns 20 ``` **Cache Key**: Uses the **first argument** as the cache key. ### When to Use βœ… **Good for**: - Functions with single argument - Functions where first argument uniquely identifies result - Simple caching needs ❌ **Not suitable for**: - Multi-argument functions (use `memoizeBy` instead) - Functions where multiple args affect result ### Examples #### Fibonacci ```typescript const fibonacci = memoize((n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) }) fibonacci(40) // Fast with memoization! ``` #### User Lookup ```typescript const getUserById = memoize((id: string): User => { return database.users.find(u => u.id === id)! }) getUserById('123') // DB query getUserById('123') // Cached ``` #### Parse JSON ```typescript const parseConfig = memoize((json: string) => { return JSON.parse(json) }) parseConfig('{"a":1}') // Parsed parseConfig('{"a":1}') // Cached ``` ### Options ```typescript interface MemoizeOptions { cache?: Cache maxSize?: number ttl?: number } ``` `ttl` expires entries after that many milliseconds, and works alongside `maxSize`. It's ignored when you pass your own `cache`. #### With maxSize ```typescript const getUser = memoize(fetchUser, { maxSize: 100 // Keep max 100 entries }) ``` #### With Custom Cache ```typescript import { ttlCache } from '@ontologyio/receta/memo' const getWeather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) // 5 min expiration }) ``` --- ## memoizeBy() Memoization with custom key extraction. ### Signature ```typescript function memoizeBy( fn: (...args: Args) => R, keyFn: (...args: Args) => K, options?: MemoizeOptions ): MemoizedFunction ``` ### How It Works ```typescript import { memoizeBy } from '@ontologyio/receta/memo' const add = memoizeBy( (a: number, b: number) => a + b, (a, b) => `${a},${b}` // Custom cache key ) add(2, 3) // Cache key: "2,3", returns 5 add(2, 3) // Cache hit, returns 5 add(3, 2) // Cache key: "3,2", computes 5 ``` **Cache Key**: Determined by the `keyFn` function. ### When to Use βœ… **Good for**: - Multi-argument functions - Object/array arguments - Custom cache key logic - Complex equality checks ### Examples #### Multi-Argument Functions ```typescript const multiply = memoizeBy( (a: number, b: number) => a * b, (a, b) => `${a}*${b}` ) multiply(3, 4) // Computed: 12 multiply(3, 4) // Cached: 12 ``` #### Object Arguments ```typescript interface FetchOptions { id: string include?: string[] } const fetchData = memoizeBy( (opts: FetchOptions) => api.fetch(opts), (opts) => JSON.stringify(opts) // Serialize object to key ) fetchData({ id: '1', include: ['profile'] }) // Computed fetchData({ id: '1', include: ['profile'] }) // Cached ``` #### Nested Property Access ```typescript interface Order { customer: { address: { country: string } } items: Item[] } const calculateShipping = memoizeBy( (order: Order) => computeShippingCost(order), (order) => order.customer.address.country // Key by country ) ``` #### Combining Multiple Args ```typescript const formatDate = memoizeBy( (date: Date, format: string, locale: string) => { return Intl.DateTimeFormat(locale).format(date) }, (date, format, locale) => `${date.getTime()}:${format}:${locale}` ) ``` ### Key Function Strategies #### Strategy 1: String Concatenation ```typescript memoizeBy( (a, b, c) => compute(a, b, c), (a, b, c) => `${a}:${b}:${c}` ) ``` #### Strategy 2: JSON Serialization ```typescript memoizeBy( (opts) => buildReport(opts), (opts) => JSON.stringify(opts) ) ``` #### Strategy 3: Object Reference ```typescript memoizeBy( (node) => transform(node), (node) => node // Use object as key (with WeakMap cache) ) ``` #### Strategy 4: Selective Properties ```typescript interface User { id: string name: string email: string } memoizeBy( (user) => formatUser(user), (user) => user.id // Only cache by ID ) ``` --- ## memoizeAsync() Async memoization with request deduplication. ### Signature ```typescript function memoizeAsync( fn: (...args: Args) => Promise, options?: MemoizeOptions & { keyFn?: KeyFn } ): MemoizedAsyncFunction ``` ### How It Works ```typescript import { memoizeAsync } from '@ontologyio/receta/memo' const fetchUser = memoizeAsync(async (id: string) => { console.log(`[API] Fetching ${id}`) const res = await fetch(`/api/users/${id}`) return res.json() }) await fetchUser('123') // Console: "[API] Fetching 123" await fetchUser('123') // (cached, no console output) ``` ### Request Deduplication **Key Feature**: Concurrent requests to the same key share a single promise. ```typescript // All 3 requests share the same promise! const [a, b, c] = await Promise.all([ fetchUser('123'), // Triggers API call fetchUser('123'), // Waits for same promise fetchUser('123') // Waits for same promise ]) console.log(a === b && b === c) // true (same object) ``` **Without memoization**: 3 separate API calls **With memoization**: 1 API call, 3 references to result ### When to Use βœ… **Good for**: - API calls - Database queries - File I/O - Any async operation - Thundering herd prevention ### Examples #### API Client ```typescript import { memoizeAsync, ttlCache } from '@ontologyio/receta/memo' class APIClient { private fetchUser = memoizeAsync( async (id: string) => { const res = await fetch(`/api/users/${id}`) return res.json() }, { cache: ttlCache(5 * 60 * 1000) } // 5 min cache ) getUser(id: string) { return this.fetchUser(id) } clearCache() { this.fetchUser.clear() } } ``` #### Database Queries ```typescript const queryUsers = memoizeAsync( async (filter: string) => { return await db.query(`SELECT * FROM users WHERE ${filter}`) }, { keyFn: (filter) => filter, cache: lruCache(50) // Cache last 50 queries } ) await queryUsers('active = true') // DB query await queryUsers('active = true') // Cached ``` #### Image Processing ```typescript interface ImageOpts { url: string width: number height: number } const resizeImage = memoizeAsync( async (opts: ImageOpts) => { const image = await loadImage(opts.url) return await resize(image, opts.width, opts.height) }, { keyFn: (opts) => `${opts.url}:${opts.width}x${opts.height}`, cache: lruCache(100) } ) ``` ### Error Handling Failed promises are **NOT cached**: ```typescript let callCount = 0 const flaky = memoizeAsync(async (id: string) => { callCount++ if (callCount === 1) throw new Error('First call fails') return `Success: ${id}` }) await flaky('123').catch(e => console.log(e.message)) // Console: "First call fails" await flaky('123') // Retries! (not cached) // Returns: "Success: 123" ``` ### Custom Key Function ```typescript interface QueryOpts { table: string where: Record limit: number } const query = memoizeAsync( async (opts: QueryOpts) => db.query(opts), { keyFn: (opts) => JSON.stringify(opts) } ) await query({ table: 'users', where: { active: true }, limit: 10 }) ``` --- ## Cache Strategies ### Default: Map Cache ```typescript const fn = memoize(expensive) // Uses: new Map() ``` **Characteristics**: - βœ… Simple, fast - βœ… No memory limit - ❌ No expiration - ❌ Can grow unbounded ### TTL Cache (Time-to-Live) ```typescript import { ttlCache } from '@ontologyio/receta/memo' const getWeather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) // 5 minutes }) ``` **Characteristics**: - βœ… Auto-expiration - βœ… Fresh data - ⚠️ Expired entries stay in memory until they are read again **When to use**: - API responses - Weather data - Stock prices - Session data - Rate-limited APIs **Example**: ```typescript import { memoize, ttlCache } from '@ontologyio/receta/memo' const fetchNews = memoize( (category: string) => api.getNews(category), { cache: ttlCache(10 * 60 * 1000) } // 10 min TTL ) fetchNews('tech') // API call fetchNews('tech') // Cached // After 10 minutes... fetchNews('tech') // API call (expired) ``` ### LRU Cache (Least Recently Used) ```typescript import { lruCache } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser, { cache: lruCache(100) // Max 100 entries }) ``` **Characteristics**: - βœ… Fixed memory usage - βœ… Keeps hot data - βœ… Auto-eviction - ⚠️ Older data lost - ⚠️ A cached `undefined` throws on the next call, because `get` treats it as missing **When to use**: - Memory-constrained environments - Large datasets - Frequently accessed items - Bounded cache size needed **Example**: ```typescript import { memoize, lruCache } from '@ontologyio/receta/memo' const processImage = memoize(transform, { cache: lruCache(50) // Keep 50 most recent }) // After 50 different images: processImage('img-001') // Cached processImage('img-050') // Cached processImage('img-051') // Computes, evicts img-002 (now the least recently used) ``` ### WeakMap Cache ```typescript import { weakCache } from '@ontologyio/receta/memo' const processNode = memoizeBy( (node: Node) => transform(node), (node) => node, // Object as key { cache: weakCache() } ) ``` **Characteristics**: - βœ… GC-friendly - βœ… No memory leaks - βœ… Perfect for objects - ❌ Only object keys - ❌ Can't iterate entries - ❌ `clear()` does nothing, because a `WeakMap` can't be emptied - ❌ A cached `null` or `undefined` throws on the next call, because `get` treats it as missing **When to use**: - Object keys (DOM nodes, etc.) - Automatic cleanup needed - Memory-sensitive applications **Example**: ```typescript import { memoizeBy, weakCache } from '@ontologyio/receta/memo' interface TreeNode { id: string children: TreeNode[] } const computeDepth = memoizeBy( (node: TreeNode): number => { if (node.children.length === 0) return 0 return 1 + Math.max(...node.children.map(computeDepth)) }, (node) => node, // Use node object as key { cache: weakCache() } ) const root: TreeNode = { id: 'root', children: [{ id: 'leaf', children: [] }] } computeDepth(root) // Computed computeDepth(root) // Cached // When `root` is garbage collected, cache entry auto-removed ``` --- ## Cache Interface All cache strategies implement this interface: ```typescript interface Cache { get(key: K): Option set(key: K, value: V): void has(key: K): boolean delete(key: K): boolean clear(): void } ``` ### Direct Cache Access ```typescript const fn = memoize(expensive) // Check cache fn.cache.has(key) // boolean // Get cached value fn.cache.get(key) // Some(value) or None // Delete entry fn.cache.delete(key) // boolean // Clear all fn.cache.clear() // void ``` ### Custom Cache Implementation ```typescript import { none, some, type Option } from '@ontologyio/receta/option' class CustomCache implements Cache { private map = new Map() get(key: K): Option { console.log(`Cache get: ${key}`) return this.map.has(key) ? some(this.map.get(key) as V) : none() } set(key: K, value: V): void { console.log(`Cache set: ${key}`) this.map.set(key, value) } has(key: K): boolean { return this.map.has(key) } delete(key: K): boolean { return this.map.delete(key) } clear(): void { this.map.clear() } } const fn = memoize(expensive, { cache: new CustomCache() }) ``` --- ## Choosing a Strategy | Use Case | Strategy | Example | |----------|----------|---------| | Simple caching | Default Map | `memoize(fn)` | | Time-sensitive data | TTL | `memoize(fn, { cache: ttlCache(60000) })` | | Memory-limited | LRU | `memoize(fn, { cache: lruCache(100) })` | | Object keys | WeakMap | `memoizeBy(fn, x => x, { cache: weakCache() })` | | Custom logic | Custom Cache | `memoize(fn, { cache: myCache })` | --- ## Next Steps - **See real-world examples** β†’ [Practical Examples](./practical-examples.mdx) - **Learn TypeScript patterns** β†’ [TypeScript Integration](./typescript-integration.mdx) - **Handle errors** β†’ [Error Handling](./error-handling.mdx) - **Best practices** β†’ [Patterns](./patterns.mdx) --- # Error Handling > Managing errors in memoized functions, especially async operations. ## Failed Promises Are Not Cached ```typescript import { memoizeAsync } from '@ontologyio/receta/memo' let callCount = 0 const flaky = memoizeAsync(async (id: string) => { callCount++ if (callCount === 1) { throw new Error('First call fails') } return `Success: ${id}` }) // First call fails await flaky('123').catch(e => console.log(e.message)) // Console: "First call fails" // Second call retries (error was not cached) const result = await flaky('123') // Returns: "Success: 123" ``` **Key Behavior**: Failed promises are automatically removed from cache, allowing retries. ## Cache Invalidation on Error ```typescript const fetchData = memoizeAsync(async (id: string) => { const res = await fetch(`/api/data/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) // Network error await fetchData('123').catch(e => { console.log('Failed:', e.message) // Cache entry for '123' is automatically removed }) // Retry immediately await fetchData('123') // Will retry the fetch ``` ## Manual Cache Invalidation ### Single Entry Invalidation ```typescript import { memoizeAsync, clearCache } from '@ontologyio/receta/memo' const getUser = memoizeAsync(async (id: string) => { return await db.users.findById(id) }) // When data changes async function updateUser(id: string, data: Partial) { await db.users.update(id, data) // Invalidate specific entry getUser.cache.delete(id) } ``` ### Batch Invalidation ```typescript import { memoizeAsync, invalidateMany } from '@ontologyio/receta/memo' const getUser = memoizeAsync(fetchUser) // After batch update async function updateUsers(ids: string[], data: Partial) { await db.users.updateMany(ids, data) // Invalidate multiple entries at once invalidateMany(getUser, ids) } ``` ### Conditional Invalidation ```typescript import { memoize, invalidateWhere } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser) // Invalidate all admin users after permission change function revokeAdminAccess() { invalidateWhere(getUser, (_key, user) => user?.role === 'admin') } // Invalidate by key pattern function clearDraftPosts() { invalidateWhere(getPost, (key: string) => key.startsWith('draft:')) } ``` `invalidateWhere` needs a cache it can walk through, so use it with the default cache, `ttlCache`, `lruCache` or a custom cache that has a `forEach` method. It throws for `weakCache`, because a WeakMap can't be listed. With `memoizeAsync`, the value it passes is the cached promise. ### Multi-Cache Invalidation ```typescript import { memoizeAsync, invalidateAll } from '@ontologyio/receta/memo' const getUser = memoizeAsync(fetchUser) const getUserPosts = memoizeAsync(fetchUserPosts) const getUserComments = memoizeAsync(fetchUserComments) // Delete user and clear all related caches async function deleteUser(id: string) { await db.users.delete(id) // Clear all user-related caches at once invalidateAll(getUser, getUserPosts, getUserComments) } ``` ### Full Cache Clear ```typescript import { clearCache } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser) // Clear entire cache clearCache(getUser) // or getUser.clear() ``` ## Invalidation Strategies | Strategy | Function | Use Case | |----------|----------|----------| | Single entry | `cache.delete(key)` | Update one item | | Multiple entries | `invalidateMany(fn, keys)` | Batch updates | | Pattern-based | `invalidateWhere(fn, predicate)` | Conditional logic | | Multi-cache | `invalidateAll(...fns)` | Related caches | | Full clear | `clearCache(fn)` or `fn.clear()` | Reset everything | ## Next Steps - **Best practices** β†’ [Patterns](./patterns.mdx) - **Migration guide** β†’ [Migration](./migration.mdx) --- # Migration Guide > Step-by-step guide to migrating from manual caching to Memo module. ## Before: Manual Caching ```typescript // ❌ Manual cache management const cache = new Map() async function getUser(id: string): Promise { if (cache.has(id)) { return cache.get(id)! } const user = await fetchUser(id) cache.set(id, user) // Manual cleanup if (cache.size > 100) { const firstKey = cache.keys().next().value as string cache.delete(firstKey) } return user } ``` **Problems**: - Manual cache logic in every function - No TTL support - No request deduplication - Error-prone cleanup - Hard to test ## After: Memo Module ```typescript // βœ… Automatic memoization import { memoizeAsync, lruCache } from '@ontologyio/receta/memo' const getUser = memoizeAsync( async (id: string) => await fetchUser(id), { cache: lruCache(100) } ) ``` **Benefits**: - Automatic caching - LRU eviction - Request deduplication - Type-safe - Testable ## Migration Steps ### Step 1: Identify Cache Patterns Look for: - `Map` used for caching - Manual cache lookups (`has`, `get`, `set`) - TTL logic with `setTimeout` - LRU eviction loops ### Step 2: Choose Cache Strategy | Current Pattern | Use | |-----------------|-----| | Simple `Map` | `memoize()` | | TTL with `setTimeout` | `ttlCache()` | | Manual LRU eviction | `lruCache()` | | `WeakMap` | `weakCache()` | ### Step 3: Replace Implementation ```typescript // Before const cache = new Map() const inFlight = new Map>() async function getData(id: string): Promise { if (cache.has(id)) return cache.get(id)! if (inFlight.has(id)) return inFlight.get(id)! const promise = fetchData(id) inFlight.set(id, promise) try { const data = await promise cache.set(id, data) return data } finally { inFlight.delete(id) } } // After import { memoizeAsync } from '@ontologyio/receta/memo' const getData = memoizeAsync(async (id: string) => await fetchData(id)) ``` ## Common Scenarios ### Scenario 1: API Response Caching **Before**: ```typescript const cache = new Map() const CACHE_TTL = 5 * 60 * 1000 async function fetchUser(id: string) { const cached = cache.get(id) if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data } const data = await api.getUser(id) cache.set(id, { data, timestamp: Date.now() }) return data } ``` **After**: ```typescript import { memoizeAsync, ttlCache } from '@ontologyio/receta/memo' const fetchUser = memoizeAsync( async (id: string) => await api.getUser(id), { cache: ttlCache(5 * 60 * 1000) } ) ``` ### Scenario 2: Expensive Computation **Before**: ```typescript const fibCache = new Map() function fibonacci(n: number): number { if (fibCache.has(n)) return fibCache.get(n)! if (n <= 1) return n const result = fibonacci(n - 1) + fibonacci(n - 2) fibCache.set(n, result) return result } ``` **After**: ```typescript import { memoize } from '@ontologyio/receta/memo' const fibonacci = memoize((n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) }) ``` ## Next Steps - **API reference** β†’ [API Reference](../reference/memo.mdx) - **Examples** β†’ [Practical Examples](./practical-examples.mdx) --- # Patterns and Best Practices > Design patterns, anti-patterns, and performance optimization. ## Best Practices ### βœ… Do: Memoize Pure Functions ```typescript // βœ… Good: Pure function const double = memoize((n: number) => n * 2) ``` ### ❌ Don't: Memoize Impure Functions ```typescript // ❌ Bad: Side effects const log = memoize((msg: string) => { console.log(msg) // Side effect! return msg }) ``` ### βœ… Do: Use Appropriate Cache Strategy ```typescript // Time-sensitive data: TTL const weather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) }) // Memory-limited: LRU const user = memoize(fetchUser, { cache: lruCache(100) }) // Object keys: WeakMap const process = memoizeBy(fn, x => x, { cache: weakCache() }) ``` ### βœ… Do: Invalidate on Mutations ```typescript const getUser = memoize(fetchUser) async function updateUser(id: string, data: any) { await db.update(id, data) getUser.cache.delete(id) // Invalidate } ``` ## Choosing Cache Strategy | Data Type | Strategy | Why | |-----------|----------|-----| | API responses | TTL | Auto-refresh stale data | | Database queries | LRU | Limit memory, keep hot data | | Computations | Map | Simple, no expiration needed | | DOM nodes | WeakMap | GC-friendly, no leaks | ## Debugging Cache Misses ```typescript const fn = memoize(expensive) // Check if cached console.log(fn.cache.has(key)) // false = cache miss // Read the cached entry console.log(fn.cache.get(key)) // Some(value), or None on a miss ``` The `Cache` interface has no size or iteration methods, so check entries by key. ## Next Steps - **Migrate existing code** β†’ [Migration Guide](./migration.mdx) - **API reference** β†’ [API Reference](../reference/memo.mdx) --- # Practical Examples > Real-world memoization patterns for common scenarios. ## Table of Contents 1. [API Caching](#api-caching) 2. [Database Queries](#database-queries) 3. [Expensive Computations](#expensive-computations) 4. [Image Processing](#image-processing) 5. [Request Deduplication](#request-deduplication) 6. [Configuration Loading](#configuration-loading) --- ## API Caching ### Basic API Response Caching ```typescript import { memoizeAsync, ttlCache } from '@ontologyio/receta/memo' const fetchUser = memoizeAsync( async (id: string) => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`Failed to fetch user ${id}`) return res.json() }, { cache: ttlCache(5 * 60 * 1000) } // 5 min cache ) // Usage const user = await fetchUser('123') // API call const same = await fetchUser('123') // Cached ``` ### Weather API with Auto-Refresh ```typescript class WeatherService { private fetchWeather = memoizeAsync( async (city: string) => { const res = await fetch(`/api/weather/${city}`) return res.json() }, { cache: ttlCache(10 * 60 * 1000) } // 10 min TTL ) async getWeather(city: string) { return this.fetchWeather(city) } refresh(city: string) { this.fetchWeather.cache.delete(city) } } ``` ### GitHub API with Rate Limiting ```typescript import { memoizeAsync, lruCache } from '@ontologyio/receta/memo' const fetchGitHubRepo = memoizeAsync( async (owner: string, repo: string) => { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`) return res.json() }, { keyFn: (owner, repo) => `${owner}/${repo}`, cache: lruCache(50) // Keep last 50 repos } ) // Usage const react = await fetchGitHubRepo('facebook', 'react') const vue = await fetchGitHubRepo('vuejs', 'vue') ``` --- ## Database Queries ### User Lookup ```typescript import { memoizeAsync, ttlCache } from '@ontologyio/receta/memo' class UserRepository { private findById = memoizeAsync( async (id: string) => { return await db.query('SELECT * FROM users WHERE id = $1', [id]) }, { cache: ttlCache(60 * 1000) } // 1 min cache ) async getUser(id: string) { return this.findById(id) } invalidate(id: string) { this.findById.cache.delete(id) } } ``` ### Complex Query Caching ```typescript interface QueryOptions { table: string where: Record limit: number offset: number } const executeQuery = memoizeAsync( async (opts: QueryOptions) => { const sql = buildSQL(opts) return await db.query(sql) }, { keyFn: (opts) => JSON.stringify(opts), cache: lruCache(100) } ) // Usage const users = await executeQuery({ table: 'users', where: { active: true }, limit: 10, offset: 0 }) ``` --- ## Expensive Computations ### Fibonacci with Memoization ```typescript import { memoize } from '@ontologyio/receta/memo' const fibonacci = memoize((n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) }) console.time('fib(40)') console.log(fibonacci(40)) // 102334155 console.timeEnd('fib(40)') // ~1ms (with memoization!) ``` ### Factorial ```typescript const factorial = memoize((n: number): number => { if (n <= 1) return 1 return n * factorial(n - 1) }) factorial(100) // Computed factorial(100) // Cached factorial(101) // Uses cached factorial(100) ``` ### Prime Number Check ```typescript const isPrime = memoize((n: number): boolean => { if (n <= 1) return false if (n <= 3) return true if (n % 2 === 0 || n % 3 === 0) return false for (let i = 5; i * i <= n; i += 6) { if (n % i === 0 || n % (i + 2) === 0) return false } return true }) isPrime(97) // Computed isPrime(97) // Cached ``` --- ## Image Processing ### Image Resizing ```typescript import { memoizeBy, lruCache } from '@ontologyio/receta/memo' interface ResizeOptions { url: string width: number height: number format: 'jpeg' | 'png' | 'webp' } const resizeImage = memoizeBy( (opts: ResizeOptions) => { console.log(`Processing: ${opts.url}`) return processImage(opts) }, (opts) => `${opts.url}:${opts.width}x${opts.height}:${opts.format}`, { cache: lruCache(50) } ) // Usage resizeImage({ url: 'photo.jpg', width: 800, height: 600, format: 'webp' }) ``` ### Thumbnail Generation ```typescript const generateThumbnail = memoizeAsync( async (imageUrl: string, size: number) => { const image = await loadImage(imageUrl) return await createThumbnail(image, size) }, { keyFn: (url, size) => `${url}:${size}`, cache: ttlCache(24 * 60 * 60 * 1000) // 24 hour cache } ) ``` --- ## Request Deduplication ### Multiple Components Requesting Same Data ```typescript import { memoizeAsync } from '@ontologyio/receta/memo' const fetchUser = memoizeAsync(async (id: string) => { console.log(`[API] Fetching user ${id}`) const res = await fetch(`/api/users/${id}`) return res.json() }) // Scenario: 3 components mount simultaneously const [header, sidebar, profile] = await Promise.all([ fetchUser('123'), // Triggers API call fetchUser('123'), // Waits for same promise fetchUser('123') // Waits for same promise ]) console.log(header === sidebar) // true (same object!) // Only 1 API call made! ``` ### Preventing Thundering Herd ```typescript const getPopularContent = memoizeAsync( async () => { console.log('[DB] Running expensive query') return await db.query('SELECT * FROM posts ORDER BY views DESC LIMIT 10') }, { cache: ttlCache(60 * 1000) } // 1 min cache ) // 1000 concurrent requests const requests = Array(1000).fill(null).map(() => getPopularContent()) await Promise.all(requests) // Only 1 DB query executed! ``` --- ## Configuration Loading ### Environment Config ```typescript import { memoize, ttlCache } from '@ontologyio/receta/memo' class ConfigLoader { private loadConfig = memoize( (env: string): AppConfig => { console.log(`Loading ${env} config`) return { apiUrl: process.env[`API_URL_${env.toUpperCase()}`]!, timeout: env === 'production' ? 5000 : 30000, debug: env !== 'production' } }, { cache: ttlCache(5 * 60 * 1000) } ) get(env: string) { return this.loadConfig(env) } } const config = new ConfigLoader() const prodConfig = config.get('production') ``` ### Feature Flags ```typescript const getFeatureFlags = memoizeAsync( async (userId: string) => { return await featureFlagService.getFlags(userId) }, { cache: ttlCache(30 * 1000) } // 30 sec cache ) // Usage const flags = await getFeatureFlags('user-123') if (flags.newUI) { // Show new UI } ``` --- ## Next Steps - **Learn TypeScript patterns** β†’ [TypeScript Integration](./typescript-integration.mdx) - **Handle errors properly** β†’ [Error Handling](./error-handling.mdx) - **Best practices** β†’ [Patterns](./patterns.mdx) --- # TypeScript Integration > Type inference, generics, and type-safe memoization patterns. ## Type Inference ### Automatic Type Inference ```typescript import { memoize } from '@ontologyio/receta/memo' // TypeScript infers types automatically const double = memoize((n: number) => n * 2) // ^? MemoizedFunction<[number], number> const result = double(5) // ^? number ``` ### Generic Functions ```typescript function createGetter(data: T[]) { return memoize((id: string): T | undefined => { return data.find((item: any) => item.id === id) }) } interface User { id: string name: string } const users: User[] = [{ id: '123', name: 'Alice' }] const getUser = createGetter(users) // ^? MemoizedFunction<[string], User | undefined> const user = getUser('123') // ^? User | undefined ``` ### Async Type Inference ```typescript const fetchUser = memoizeAsync(async (id: string) => { const res = await fetch(`/api/users/${id}`) return res.json() as Promise }) // ^? MemoizedAsyncFunction<[string], User> const user = await fetchUser('123') // ^? User ``` ## Cache Types ```typescript interface Cache { get(key: K): Option set(key: K, value: V): void has(key: K): boolean delete(key: K): boolean clear(): void } ``` ## Next Steps - **Handle errors** β†’ [Error Handling](./error-handling.mdx) - **Best practices** β†’ [Patterns](./patterns.mdx) --- # Receta vs neverthrow: a neverthrow alternative Looking for a neverthrow alternative? Compare Receta and neverthrow on plain data, async chaining, bundle size and migration, with code run in both. Both libraries give TypeScript a `Result` type, so a failure shows up in a function's signature instead of being thrown. neverthrow does that one job, and it's the established choice. Receta makes `Result` a plain value and adds the tools around it: `Option`, retries, concurrency limits, validation and more. **Choose neverthrow** if you want one small, proven dependency, or if your code is mostly chains of async steps. **Choose Receta** if your results cross a boundary such as server to client, or if you'd otherwise add separate packages for retries, concurrency and validation. > We make Receta, so read this with that in mind. Every sample and number here was run on November 6, 2026, against neverthrow 8.2.0 and Receta 0.4.2. If something is out of date or unfair to neverthrow, [open an issue](https://github.com/ontology-io/receta/issues). ## At a glance | | neverthrow | Receta | | --- | --- | --- | | **Style** | Methods on a `Result` class | Functions on plain objects, used with `pipe` | | **Scope** | `Result` | `Result`, `Option`, async, validation and 9 more modules | | **Chaining async steps** | `ResultAsync`, no `await` needed | `await` each step | | **Survives JSON and `structuredClone`** | No, the methods are lost | Yes | | **Runtime dependencies** | None | [Remeda](https://remedajs.com) | | **Size of the example below** | 2.1 KB gzipped | 1.4 KB gzipped | | **Install size** | 112 KB | 2.2 MB, plus Remeda | | **First npm release** | April 2019 | February 2026 | | **Latest npm release** | 8.2.0, February 2025 | 0.4.2, November 2026 | | **Weekly npm downloads** | About 5.1 million | About 200 | | **License** | MIT | MIT | Downloads are for the week ending November 4, 2026. ## The same code in both This function parses a config string and reports the port. It can fail in two ways, and both versions return the same text for valid JSON, a port of the wrong type, and broken JSON. With neverthrow, each step is a method on the result: ```ts nocheck import { err, fromThrowable, ok, type Result } from 'neverthrow' type ConfigError = { code: 'INVALID_JSON' } | { code: 'INVALID_PORT' } type Config = { port: number } const parseJson = fromThrowable( (raw: string): unknown => JSON.parse(raw), (): ConfigError => ({ code: 'INVALID_JSON' }), ) const requirePort = (value: unknown): Result => { const port = (value as { port?: unknown }).port return typeof port === 'number' ? ok({ port }) : err({ code: 'INVALID_PORT' }) } const describePort = (raw: string): string => parseJson(raw) .andThen(requirePort) .map((config) => config.port) .match( (port) => `Listening on ${port}`, (error) => `Bad config: ${error.code}`, ) ``` With Receta, each step is a stage in a pipe: ```ts import * as R from 'remeda' import { err, flatMap, map, match, ok, tryCatch, type Result } from '@ontologyio/receta/result' type ConfigError = { code: 'INVALID_JSON' } | { code: 'INVALID_PORT' } type Config = { port: number } const parseJson = (raw: string): Result => tryCatch( () => JSON.parse(raw) as unknown, (): ConfigError => ({ code: 'INVALID_JSON' }), ) const requirePort = (value: unknown): Result => { const port = (value as { port?: unknown }).port return typeof port === 'number' ? ok({ port }) : err({ code: 'INVALID_PORT' }) } const describePort = (raw: string): string => R.pipe( parseJson(raw), flatMap(requirePort), map((config) => config.port), match({ onOk: (port) => `Listening on ${port}`, onErr: (error) => `Bad config: ${error.code}`, }), ) describePort('{"port":3000}') // => 'Listening on 3000' describePort('not json') // => 'Bad config: INVALID_JSON' ``` The two are close to the same length. Methods give you autocomplete after the dot and need no import per operation. Functions let you write your own operations that work like the built-in ones, and let a bundler drop what you don't use. ## Where they really differ ### Plain data or class instances A Receta `Result` is an ordinary object: ```ts import { ok } from '@ontologyio/receta/result' JSON.stringify(ok(1)) // => '{"_tag":"Ok","value":1}' ``` A neverthrow `Result` is an instance of a class, and its behaviour lives on that class: ```ts nocheck import { ok } from 'neverthrow' JSON.stringify(ok(1)) // '{"value":1}' structuredClone(ok(1)).isOk // undefined, the methods are gone ``` This matters when a result leaves the process or the thread: a worker's `postMessage`, a queue, a cache. It also matters in Next.js, which refuses to pass class instances from a Server Component to a Client Component. With neverthrow you convert at each boundary. With Receta the result crosses as it is, and `isOk` still works on the other side. If your results never leave the function that made them, this difference won't affect you. ### Async chaining **neverthrow is better here.** `ResultAsync` wraps a promise of a result, so you can keep chaining without stopping to `await`: ```ts nocheck import { ResultAsync } from 'neverthrow' const loadOrders = (id: string): ResultAsync => ResultAsync.fromPromise(fetchUser(id), toApiError).andThen((user) => ResultAsync.fromPromise(fetchOrders(user), toApiError), ) ``` Receta's `flatMap` has no async form, so each step needs an `await` and an early return: ```ts import { isErr, tryCatchAsync, type Result } from '@ontologyio/receta/result' type Order = { total: number } type ApiError = { code: 'REQUEST_FAILED' } const loadOrders = async (id: string): Promise> => { const user = await tryCatchAsync(() => fetchUser(id), toApiError) if (isErr(user)) return user return tryCatchAsync(() => fetchOrders(user.value), toApiError) } ``` That's fine for two steps and tiring for six. neverthrow also has `safeTry`, which gives you early returns through a generator, and `orElse` for recovering from an error. Receta has no `safeTry`, and you recover with `match`. ### Everything around Result neverthrow stops at `Result`, and that focus is a large part of its appeal. For the rest, you pick other packages. Receta includes those pieces, and they return a `Result` you can keep piping: ```ts import { mapAsync, retry } from '@ontologyio/receta/async' const users = await mapAsync(ids, fetchUser, { concurrency: 5 }) const report = await retry(() => fetchReport(id), { maxAttempts: 3, delay: 200 }) ``` Validation reports every problem at once, not just the first: ```ts import { fromPredicate, schema, type ValidationSchema } from '@ontologyio/receta/validation' type Signup = { email: string; age: number } const rules: ValidationSchema = { email: fromPredicate((email: string) => email.includes('@'), 'Email must contain @'), age: fromPredicate((age: number) => age >= 18, 'Must be 18 or over'), } const validateSignup = schema(rules) validateSignup({ email: 'nope', age: 12 }) // Invalid, with one error for email and one for age ``` There's also [`Option`](./option/index.mdx) for values that may be missing, which neverthrow doesn't have. See [Choose a module](./choose-a-module.mdx) for the rest. ### Lint rules The two plugins solve different problems. neverthrow's README recommends [`eslint-plugin-neverthrow`](https://www.npmjs.com/package/eslint-plugin-neverthrow), which fails the lint when a `Result` is ignored. That plugin's latest release is from May 2022, so check that it works with your ESLint version. Receta has no rule like it. `@ontologyio/eslint-plugin-receta` finds code that hasn't moved yet: `try/catch`, functions that return `null`, and array method chains. It can rewrite many of them for you. ### Bundle size Neither library will be what makes your app heavy. Measured with esbuild, minified, then gzipped: | What you import | neverthrow | Receta | | --- | --- | --- | | Just `ok` | 2.0 KB | 0.1 KB | | The async example above | 2.1 KB | 0.3 KB | | The config example above, with `R.pipe` | 2.1 KB | 1.4 KB | neverthrow's size barely moves, because its methods live on classes and a bundler keeps all of them. Receta's grows with what you import. Receta is the larger install, at 2.2 MB unpacked against 112 KB. More than half of that is source maps, so it costs disk space, not bundle size. ## When to choose neverthrow - You want `Result` and nothing else, with no runtime dependencies. - Your code is mostly chains of async steps that can fail. - You want a long track record. neverthrow has been on npm since 2019, with millions of weekly downloads. Receta is young, has few users so far, and is at 0.4, so its API can still change. - Your team prefers method chains and autocomplete after the dot. - You want the linter to fail when a `Result` is ignored. ## When to choose Receta - Your results cross a boundary: server to client, workers, queues or caches. - You already use Remeda, or you like working in `pipe`. - You'd otherwise install separate packages for retries, concurrency, validation or missing values. - You want lint rules that find the `try/catch` and `null` returns still in your code. ## Moving from neverthrow You don't have to move everything at once. Convert at the edge of the code you've migrated: ```ts nocheck import type { Result as NeverthrowResult } from 'neverthrow' import { err, ok, type Result } from '@ontologyio/receta/result' const fromNeverthrow = (result: NeverthrowResult): Result => result.match>( (value) => ok(value), (error) => err(error), ) ``` Each row below was run in both libraries and gave the same result: | neverthrow | Receta | | --- | --- | | `ok(value)`, `err(error)` | `ok(value)`, `err(error)` | | `result.isOk()`, `result.isErr()` | `isOk(result)`, `isErr(result)` | | `result.map(fn)` | `map(result, fn)` | | `result.mapErr(fn)` | `mapErr(result, fn)` | | `result.andThen(fn)` | `flatMap(result, fn)` | | `result.orElse(fn)` | `match(result, { onOk: (value) => ok(value), onErr: fn })` | | `result.match(onOk, onErr)` | `match(result, { onOk, onErr })` | | `result.unwrapOr(fallback)` | `unwrapOr(result, fallback)` | | `result.andTee(fn)` | `tap(result, fn)` | | `result.orTee(fn)` | `tapErr(result, fn)` | | `result._unsafeUnwrap()` | `unwrap(result)` | | `fromThrowable(fn, toError)()` | `tryCatch(fn, toError)` | | `ResultAsync.fromPromise(promise, toError)` | `tryCatchAsync(() => promise, toError)` | | `Result.combine(results)` | `collect(results)` | | `Result.combineWithAllErrors(results)` | `partition(results)`, which returns `[values, errors]` | Two things have no direct equivalent: `ResultAsync` chaining and `safeTry`. ## Next steps - [Install Receta](./installation.mdx) - [Read the Result guide](./result/index.mdx) - [Move from try/catch to Result](./result/migration.mdx) - [Read neverthrow's documentation](https://github.com/supermacro/neverthrow) --- # 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. ## Table of Contents 1. [The Problem: Hidden Dangers in Object Operations](#the-problem-hidden-dangers-in-object-operations) 2. [How Production APIs Handle This](#how-production-apis-handle-this) 3. [The Solution: Receta Object Module](#the-solution-receta-object-module) 4. [Why Object Over Lodash/Vanilla JS?](#why-object-over-lodashvanilla-js) 5. [Real-World Use Cases](#real-world-use-cases) 6. [Mental Model](#mental-model) 7. [When to Use Object](#when-to-use-object) 8. [What's Next?](#whats-next) --- ## The Problem: Hidden Dangers in Object Operations ### Real-World Example: API Response Transformation You're building a dashboard that aggregates data from multiple APIs. Each API returns different object structures that need to be normalized: ```typescript // GitHub API - snake_case with nested data const githubUser = { login: 'alice', avatar_url: 'https://...', public_repos: 42, created_at: '2015-01-01T00:00:00Z', plan: { name: 'pro', collaborators: 10 } } // Stripe API - different nesting, mixed naming const stripeCustomer = { id: 'cus_123', email: 'alice@example.com', metadata: { github_username: 'alice', plan_tier: 'premium' }, subscriptions: { data: [{ status: 'active' }] } } // Your frontend expects: flat, camelCase, consistent structure interface User { id: string username: string email: string avatarUrl: string planTier: string isActive: boolean } ``` **Traditional approach has hidden dangers:** ```typescript // ❌ Problem 1: Unsafe nested access (runtime errors) const planName = stripeCustomer.subscriptions.data[0].plan.name // TypeError! // ❌ Problem 2: Manual key transformation (error-prone) const normalized = { avatarUrl: githubUser.avatar_url, // Easy to miss one // ... 20 more fields to manually rename } // ❌ Problem 3: Mutations leak function updateConfig(config) { config.database.host = 'prod.example.com' // Mutates original! return config } // ❌ Problem 4: No validation (silent failures) const theme = config.ui?.theme ?? 'light' // What if config.ui is a string? // ❌ Problem 5: Deep merging is tricky const merged = { ...defaults, ...userConfig } // Only shallow merge! ``` ### How This Manifests in Production **At Stripe:** ```typescript // Normalizing payment methods from different providers const paymentMethod = { type: 'card', card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2025 } } // Need to flatten for display: { cardBrand: 'visa', cardLast4: '4242', ... } // Traditional approach: 50+ lines of manual transformation ``` **At GitHub:** ```typescript // Merging user preferences across devices const desktopPrefs = { theme: 'dark', editor: { fontSize: 14, tabSize: 2 } } const mobilePrefs = { theme: 'light', editor: { lineNumbers: true } } // Need: { theme: 'light', editor: { fontSize: 14, tabSize: 2, lineNumbers: true } } // Object.assign() loses nested data! ``` --- ## How Production APIs Handle This ### Stripe API Normalization A Stripe `payment_intent` response: ```json { "id": "pi_123", "object": "payment_intent", "amount": 2000, "metadata": { "order_id": "ord_456", "customer_tier": "premium" }, "charges": { "data": [ { "id": "ch_789", "payment_method_details": { "card": { "brand": "visa", "last4": "4242" } } } ] } } ``` Normalizing a response like this usually means: 1. Flatten the nested metadata 2. Extract the deeply nested payment details 3. Rename keys to match your schema 4. Validate the structure before processing ### GitHub API Transformation A GitHub repository object (partial): ```json { "name": "receta", "full_name": "company/receta", "owner": { "login": "alice", "avatar_url": "https://..." }, "created_at": "2024-01-01T00:00:00Z", "pushed_at": "2024-01-20T15:30:00Z" } ``` A frontend consuming it usually wants to: 1. Rename snake_case β†’ camelCase 2. Flatten the owner data 3. Parse the ISO dates 4. Add computed fields (e.g., isStale) ### Next.js Configuration Merging ```typescript // next.config.js deep merge behavior const defaultConfig = { images: { domains: ['example.com'], deviceSizes: [640, 750, 828] }, webpack: (config) => config } const userConfig = { images: { domains: ['cdn.example.com'], // Should merge, not replace formats: ['image/avif'] } } // Next.js merges deeply while preserving defaults ``` --- ## The Solution: Receta Object Module The Object module provides **17 functions** across 4 categories: ### 1. Core Operations (5 functions) ```typescript import * as Obj from '@ontologyio/receta/object' import { pipe } from 'remeda' // Flatten: Nested β†’ Dot notation const nested = { user: { profile: { name: 'Alice' } } } Obj.flatten(nested) // => { 'user.profile.name': 'Alice' } // Unflatten: Dot notation β†’ Nested const flat = { 'user.name': 'Alice', 'user.age': 30 } Obj.unflatten(flat) // => { user: { name: 'Alice', age: 30 } } // Rename: Transform keys const snakeCase = { first_name: 'Alice', last_name: 'Smith' } Obj.rename(snakeCase, { first_name: 'firstName', last_name: 'lastName' }) // => { firstName: 'Alice', lastName: 'Smith' } // Mask: Security-focused filtering const user = { id: 1, email: 'alice@example.com', passwordHash: 'secret' } Obj.mask(user, ['id', 'email']) // => { id: 1, email: 'alice@example.com' } // DeepMerge: Intelligent deep merging const defaults = { tags: ['a'], theme: 'light' } const userConfig = { tags: ['b'] } Obj.deepMerge([defaults, userConfig], { arrayStrategy: 'concat' }) // => { tags: ['a', 'b'], theme: 'light' } ``` ### 2. Safe Access (3 functions) ```typescript import { getPath, setPath, updatePath } from '@ontologyio/receta/object' import { unwrapOr } from '@ontologyio/receta/option' // The path comes first and the object last // GetPath: Returns Option (no runtime errors!) const config = { database: { host: 'localhost' } } const host = getPath(['database', 'host'], config) // => Some('localhost') const missing = getPath(['api', 'key'], config) // => None // SetPath: Immutable updates const updated = setPath(['database', 'port'], 5432, config) // => { database: { host: 'localhost', port: 5432 } } // Original config unchanged! // UpdatePath: Transform the value at a path, returning a new object const user = { profile: { views: 10 } } updatePath(['profile', 'views'], (n: number) => n + 1, user) // => { profile: { views: 11 } } ``` ### 3. Validation (4 functions) ```typescript import { validateShape, stripUndefined, compact, stripEmpty } from '@ontologyio/receta/object' import { isOk } from '@ontologyio/receta/result' // ValidateShape: Runtime type checking const userSchema = { id: (v: unknown): v is number => typeof v === 'number', email: (v: unknown): v is string => typeof v === 'string' && v.includes('@') } const result = validateShape(input, userSchema) if (isOk(result)) { // TypeScript knows this is validated! console.log(result.value.email) } // StripUndefined: Clean payloads const payload = { name: 'Alice', age: undefined, email: 'alice@example.com' } stripUndefined(payload) // => { name: 'Alice', email: 'alice@example.com' } // Compact: Remove nullish values compact({ name: 'Alice', age: null, email: undefined }) // => { name: 'Alice' } // StripEmpty: Also remove empty strings, arrays and objects, at any depth stripEmpty({ name: 'Alice', bio: '', tags: [], address: { city: '' } }) // => { name: 'Alice' } ``` ### 4. Transformation (5 functions) ```typescript import { mapKeys, mapValues, filterKeys, filterValues, transformKeys } from '@ontologyio/receta/object' // MapKeys: Transform all keys mapKeys({ name: 'Alice' }, (key) => key.toUpperCase()) // => { NAME: 'Alice' } // TransformKeys: Change the naming convention, nested objects included transformKeys({ first_name: 'Alice', home_address: { zip_code: '10001' } }, 'camelCase') // => { firstName: 'Alice', homeAddress: { zipCode: '10001' } } // MapValues: Transform all values mapValues(prices, (price) => price * 1.1) // FilterKeys: Keep matching keys filterKeys(config, (key) => key.startsWith('api_')) // FilterValues: Keep matching values filterValues(scores, (score) => score >= 70) ``` --- ## Why Object Over Lodash/Vanilla JS? ### Problem 1: Lodash Mutates or Forces Cloning ```typescript // ❌ Lodash: Manual deep cloning required import _ from 'lodash' const original = { a: { b: 1 } } const updated = _.set(_.cloneDeep(original), 'a.b', 2) // Verbose, error-prone, easy to forget cloneDeep // βœ… Receta: Immutable by default const updated = setPath(['a', 'b'], 2, original) // Original unchanged, no cloning needed ``` ### Problem 2: Optional Chaining Doesn't Compose ```typescript // ❌ Vanilla: The path is fixed in the source and you get a bare value const value = obj?.deeply?.nested?.value ?? 'default' // βœ… Receta: The path is a value, and the Option works in pipes const value = pipe( obj, getPath(['deeply', 'nested', 'value']), unwrapOr('default') ) ``` ### Problem 3: No Pipeline Integration ```typescript import { map } from '@ontologyio/receta/result' // ❌ Lodash: Forced to use chain() or nest calls const result = _.flatMap( _.filter( _.map(data, transform), predicate ), expand ) // βœ… Receta: Natural pipeline composition const result = pipe( apiResponse, Obj.rename(keyMapping), Obj.stripUndefined, Obj.validateShape(apiSchema), map(processValidData) ) ``` ### Problem 4: No Built-in Validation ```typescript // ❌ Lodash/Vanilla: Manual validation if (typeof obj.id === 'number' && typeof obj.email === 'string') { // Process... } // βœ… Receta: First-class validation const result = validateShape(obj, userSchema) // Returns Result with error details ``` ### Comparison Table | Feature | Vanilla JS | Lodash | Receta Object | |---------|-----------|--------|---------------| | Immutable | ❌ No | ⚠️ Manual | βœ… Always | | Safe access | ⚠️ `?.` only | ⚠️ `_.get` | βœ… `Option` | | Pipeline | ❌ No | ⚠️ chain() | βœ… Native | | Validation | ❌ No | ❌ No | βœ… Built-in | | Type-safe | ⚠️ Partial | ❌ No | βœ… Full | | Tree-shakeable | N/A | ❌ No | βœ… Yes | | Deep merge | ❌ Shallow | βœ… Yes | βœ… Configurable | | Flatten objects | ❌ No | ❌ Arrays only | βœ… Yes | --- ## Real-World Use Cases ### 1. API Response Normalization ```typescript import { flatMap, map } from '@ontologyio/receta/result' // Stripe payment β†’ Your DB schema const normalizePayment = (stripePayment: unknown) => pipe( stripePayment, validateShape(stripePaymentSchema), map((payment) => Obj.flatten(payment)), map(Obj.rename(keyMapping)), map(stripUndefined), flatMap(saveToDatabase) ) ``` ### 2. Configuration Merging ```typescript import { unwrapOr } from '@ontologyio/receta/result' // Next.js-style deep merge const mergedConfig = pipe( [defaultConfig, envConfig, userConfig], Obj.deepMerge({ arrayStrategy: 'concat' }), Obj.validateShape(configSchema), unwrapOr(defaultConfig) ) ``` ### 3. Form Data Sanitization ```typescript // Clean form data before API call const sanitizeForm = (formData: Record) => pipe( formData, Obj.stripUndefined, Obj.filterValues((v) => v !== ''), Obj.mask(allowedFields), // Security: only send allowed fields Obj.validateShape(formSchema) ) ``` ### 4. Database Query Builder ```typescript // Build where clause from object const buildWhere = (filters: Record) => pipe( filters, Obj.compact, (active) => Obj.flatten(active, { separator: '_' }), Obj.mapKeys((key) => `table.${key}`), Obj.filterValues((v) => v != null) ) ``` ### 5. Security: Sanitizing Responses ```typescript // Remove sensitive data before sending to client const sanitizeUser = (dbUser: DbUser) => pipe( dbUser, Obj.mask(['id', 'username', 'email', 'avatar']), // Allowlist Obj.rename({ username: 'displayName' }), Obj.updatePath(['avatar'], (url: string) => resizeImage(url)) ) ``` --- ## Mental Model Think of the Object module as a **safe construction toolkit** for object manipulation: ``` Traditional approach: Receta approach: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Raw Object β”‚ β”‚ Raw Object β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ Manual ops β”‚ Safe ops β”‚ (error-prone) β”‚ (type-safe) ↓ ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Hope it β”‚ β”‚ Validated β”‚ β”‚ works! 🀞 β”‚ β”‚ Result β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` **Key principles:** 1. **Immutable**: Original objects never change 2. **Type-safe**: TypeScript guides you 3. **Composable**: Works in pipe chains 4. **Explicit**: Option/Result for safe handling 5. **Validated**: Runtime checks return Result --- ## When to Use Object ### βœ… Use Object when: - **API integration**: Normalizing different API response shapes - **Configuration**: Deep merging config files (Next.js, Vite, etc.) - **Forms**: Sanitizing and validating form data - **Security**: Masking sensitive fields before logging/sending - **Data transformation**: Flattening/unflattening for databases - **Nested updates**: Immutably updating deep structures - **Type checking**: Runtime validation of unknown data ### ❌ Don't use Object for: - **Simple access**: Use optional chaining `obj?.prop` - **Creating objects**: Use object literals `{ ... }` - **Shallow operations**: Use spread `{ ...obj }` - **Class instances**: Stick with methods - **Performance-critical hot paths**: Profile first ### Decision Tree ``` Need to work with objects? β”‚ β”œβ”€ Just reading a known property? β”‚ β†’ Use optional chaining: obj?.prop β”‚ β”œβ”€ Creating/updating one level deep? β”‚ β†’ Use spread: { ...obj, newKey: value } β”‚ β”œβ”€ Working with nested structure? β”‚ β”‚ β”‚ β”œβ”€ Safe access needed? β”‚ β”‚ β†’ Use getPath() β†’ Option β”‚ β”‚ β”‚ β”œβ”€ Need to update? β”‚ β”‚ β†’ Use setPath() / updatePath() β”‚ β”‚ β”‚ └─ Need to flatten? β”‚ β†’ Use flatten() / unflatten() β”‚ β”œβ”€ Transforming many keys/values? β”‚ β†’ Use mapKeys() / mapValues() β”‚ β”œβ”€ Merging configs/data? β”‚ β†’ Use deepMerge() β”‚ β”œβ”€ Sanitizing for API/security? β”‚ β†’ Use mask() + stripUndefined() β”‚ └─ Validating unknown data? β†’ Use validateShape() β†’ Result ``` --- ## What's Next? Now that you understand why the Object module exists and when to use it, explore: 1. **[Core Operations](./core-operations.mdx)** - flatten, unflatten, rename, mask, deepMerge 2. **[Safe Access](./safe-access.mdx)** - getPath, setPath, updatePath 3. **[Validation](./validation.mdx)** - validateShape, stripUndefined, compact 4. **[Transformation](./transformation.mdx)** - mapKeys, mapValues, filterKeys, filterValues 5. **[Patterns & Recipes](./patterns.mdx)** - Copy-paste ready solutions 6. **[Migration Guide](./migration.mdx)** - From Lodash/Vanilla to Receta 7. **[API Reference](../reference/object.mdx)** - Quick lookup and cheat sheets --- # Core Operations The core operations provide fundamental transformations for restructuring objects. These 5 functions handle the most common scenarios: flattening hierarchies, renaming keys, filtering fields, and deep merging. ## Table of Contents 1. [flatten](#flatten) 2. [unflatten](#unflatten) 3. [rename](#rename) 4. [mask](#mask) 5. [deepMerge](#deepmerge) 6. [Comparison Table](#comparison-table) 7. [When to Use Which](#when-to-use-which) --- ## flatten Converts nested objects into flat objects with dot-notation keys. ### Signature ```typescript title="Signature" function flatten(obj: PlainObject, options?: FlattenOptions): FlatObject interface FlattenOptions { separator?: string // Default: '.' maxDepth?: number // Default: Infinity flattenArrays?: boolean // Default: false } ``` ### Real-World: Database Query Parameters ```typescript // ElasticSearch-style query builder const searchQuery = { query: { bool: { must: [ { match: { title: 'receta' } } ], filter: { range: { date: { gte: '2024-01-01' } } } } } } // Flatten for logging/debugging. Arrays stay whole unless you set flattenArrays. const flat = flatten(searchQuery) // { // 'query.bool.must': [{ match: { title: 'receta' } }], // 'query.bool.filter.range.date.gte': '2024-01-01' // } ``` ### Real-World: Form Data to URL Params ```typescript // Next.js search params const filters = { category: 'electronics', price: { min: 100, max: 500 }, features: { wireless: true, waterproof: false } } const params = new URLSearchParams( mapValues(flatten(filters, { separator: '_' }), String) ) // category=electronics&price_min=100&price_max=500&features_wireless=true... ``` ### Options Examples ```typescript const nested = { user: { profile: { name: 'Alice', age: 30 } } } // Custom separator flatten(nested, { separator: '_' }) // => { user_profile_name: 'Alice', user_profile_age: 30 } // Max depth flatten(nested, { maxDepth: 1 }) // => { 'user.profile': { name: 'Alice', age: 30 } } // Flatten arrays const data = { items: ['a', 'b', 'c'] } flatten(data, { flattenArrays: true }) // => { 'items.0': 'a', 'items.1': 'b', 'items.2': 'c' } ``` ### In Pipelines `flatten()` and `unflatten()` work in a pipe with no arguments. With options, wrap them in an arrow function, because TypeScript reads `flatten({ separator: '__' })` as the data-first call. ```typescript import { pipe } from 'remeda' const config = { database: { host: 'localhost', port: '5432' } } // Prepare nested config for environment variables const envVars = pipe( config, (c) => flatten(c, { separator: '__' }), mapKeys((key) => key.toUpperCase()) ) // => { DATABASE__HOST: 'localhost', DATABASE__PORT: '5432' } ``` --- ## unflatten Converts flat objects with dot-notation keys back into nested structures. ### Signature ```typescript title="Signature" function unflatten(obj: FlatObject, options?: UnflattenOptions): PlainObject interface UnflattenOptions { separator?: string // Default: '.' } ``` ### Real-World: Environment Variables to Config ```typescript // Load environment variables const env = { 'DATABASE_HOST': 'localhost', 'DATABASE_PORT': '5432', 'API_KEY': 'secret', 'API_TIMEOUT': '30000' } // Convert to nested config const config = pipe( env, mapKeys((key) => key.toLowerCase()), (flat) => unflatten(flat, { separator: '_' }) ) // { // database: { host: 'localhost', port: '5432' }, // api: { key: 'secret', timeout: '30000' } // } ``` ### Real-World: Redis Hash to Object ```typescript // Redis stores flat key-value pairs const redisHash = { 'user:123:name': 'Alice', 'user:123:email': 'alice@example.com', 'user:123:settings:theme': 'dark', 'user:123:settings:notifications': 'true' } const user = unflatten( removePrefix(redisHash, 'user:123:'), { separator: ':' } ) // { // name: 'Alice', // email: 'alice@example.com', // settings: { theme: 'dark', notifications: 'true' } // } ``` ### Array Reconstruction ```typescript // Unflatten reconstructs arrays from numeric keys const flat = { 'items.0': 'apple', 'items.1': 'banana', 'items.2.name': 'orange', 'items.2.color': 'orange' } unflatten(flat) // { // items: [ // 'apple', // 'banana', // { name: 'orange', color: 'orange' } // ] // } ``` ### Round-Trip `unflatten` restores what `flatten` produced, as long as no key contains the separator. A key like `'a.b'` comes back nested. ```typescript const original = { a: { b: { c: 1 } } } const restored = unflatten(flatten(original)) // => { a: { b: { c: 1 } } } ``` --- ## rename Renames object keys according to a mapping, preserving unmapped keys. ### Signature ```typescript title="Signature" function rename(obj: T, mapping: Record): PlainObject ``` ### Real-World: API Response Normalization ```typescript // GitHub API β†’ Your schema const githubUser = { login: 'alice', avatar_url: 'https://github.com/alice.png', public_repos: 42, created_at: '2015-01-01T00:00:00Z' } const normalized = rename(githubUser, { login: 'username', avatar_url: 'avatarUrl', public_repos: 'repoCount', created_at: 'joinedAt' }) // { // username: 'alice', // avatarUrl: 'https://github.com/alice.png', // repoCount: 42, // joinedAt: '2015-01-01T00:00:00Z' // } ``` ### Real-World: Database Schema Migration ```typescript // Rename columns after schema change const oldRecord = { user_id: 123, first_name: 'Alice', last_name: 'Smith', email_address: 'alice@example.com' } const newRecord = rename(oldRecord, { user_id: 'id', first_name: 'firstName', last_name: 'lastName', email_address: 'email' }) ``` ### Partial Mapping ```typescript // Only rename specific keys, keep others unchanged const data = { id: 1, oldName: 'Alice', age: 30, city: 'NYC' } rename(data, { oldName: 'name' }) // { id: 1, name: 'Alice', age: 30, city: 'NYC' } // ↑ Unmapped keys preserved ``` ### In Pipelines with Multiple APIs ```typescript // Normalize responses from different APIs const normalizeStripeCustomer = (customer: PlainObject) => pipe( customer, rename({ id: 'customerId', email: 'email', created: 'createdAt', metadata: 'meta' }), validateShape(customerSchema) ) ``` --- ## mask Security-focused filtering that keeps only explicitly allowed keys (allowlist approach). ### Signature ```typescript title="Signature" function mask(obj: T, allowedKeys: readonly string[]): Partial ``` ### Real-World: Sanitize Before Logging ```typescript // Remove sensitive data before logging const request = { userId: 123, email: 'alice@example.com', password: 'hunter2', creditCard: '4111-1111-1111-1111', apiKey: 'sk_live_...', timestamp: 1234567890 } const sanitized = mask(request, ['userId', 'email', 'timestamp']) logger.info('User request', sanitized) // Logs: { userId: 123, email: 'alice@example.com', timestamp: 1234567890 } ``` ### Real-World: API Response Filtering ```typescript // Only send necessary fields to client const dbUser = { id: 1, username: 'alice', email: 'alice@example.com', passwordHash: '$2b$10$...', salt: 'random_salt', internalNotes: 'VIP customer', createdAt: new Date(), updatedAt: new Date() } const publicUser = mask(dbUser, ['id', 'username', 'email', 'createdAt']) // Only safe fields exposed to client ``` ### Real-World: GDPR Data Minimization ```typescript // Only collect necessary analytics data const userAction = { userId: 123, fullName: 'Alice Smith', email: 'alice@example.com', ipAddress: '192.168.1.1', action: 'purchase', amount: 99.99, timestamp: Date.now() } // GDPR: collect minimal data const analytics = mask(userAction, ['action', 'amount', 'timestamp']) // No PII stored in analytics ``` ### mask vs Remeda.pick ```typescript // Same behavior, different semantic intent // Remeda.pick: "I want these properties" const selected = R.pick(obj, ['id', 'name']) // Object.mask: "I only allow these properties (security)" const safe = mask(obj, ['id', 'name']) // Use mask when security/privacy is the concern ``` ### In Pipelines ```typescript // Complete sanitization pipeline const sanitizeUser = (dbUser: DbUser) => pipe( dbUser, mask(['id', 'username', 'email', 'avatar', 'role']), stripUndefined, rename({ username: 'displayName' }) ) ``` --- ## deepMerge Deep merge multiple objects with configurable array handling strategies. ### Signature ```typescript title="Signature" function deepMerge( objects: readonly PlainObject[], options?: DeepMergeOptions ): PlainObject interface DeepMergeOptions { arrayStrategy?: 'replace' | 'concat' | 'merge' // Default: 'replace' customMerge?: (key: string, target: unknown, source: unknown) => unknown } ``` ### Real-World: Configuration Merging (Next.js Style) ```typescript // next.config.js pattern const defaultConfig = { images: { domains: ['example.com'], deviceSizes: [640, 750, 828, 1080] }, webpack: { /* defaults */ }, env: { API_URL: 'https://api.example.com' } } const userConfig = { images: { domains: ['cdn.example.com'], // Should merge, not replace formats: ['image/avif', 'image/webp'] }, env: { API_URL: 'https://prod.example.com', // Override DEBUG: 'true' } } const finalConfig = deepMerge([defaultConfig, userConfig]) // { // images: { // domains: ['cdn.example.com'], // Replaced (default strategy) // deviceSizes: [640, 750, 828, 1080], // Preserved // formats: ['image/avif', 'image/webp'] // Added // }, // webpack: { /* defaults */ }, // env: { // API_URL: 'https://prod.example.com', // DEBUG: 'true' // } // } ``` ### Array Strategy: replace (default) ```typescript const obj1 = { tags: ['javascript', 'typescript'] } const obj2 = { tags: ['react'] } // Source replaces target deepMerge([obj1, obj2]) // => { tags: ['react'] } ``` ### Array Strategy: concat ```typescript const obj1 = { tags: ['javascript', 'typescript'] } const obj2 = { tags: ['react'] } // Useful for accumulating arrays deepMerge([obj1, obj2], { arrayStrategy: 'concat' }) // => { tags: ['javascript', 'typescript', 'react'] } ``` ### Array Strategy: merge Merges arrays by index. Objects and arrays merge, and anything else takes the source value. The result is as long as the source array, so target items past its end are dropped. ```typescript const defaults = { plugins: [ { name: 'plugin-a', enabled: true }, { name: 'plugin-b', enabled: true } ] } const overrides = { plugins: [ { enabled: false } // Disable first plugin ] } // plugin-b is gone: the source array has one item deepMerge([defaults, overrides], { arrayStrategy: 'merge' }) // => { plugins: [{ name: 'plugin-a', enabled: false }] } ``` ### Custom Merge Function ```typescript // Advanced: custom merge logic per key const obj1 = { count: 5, views: 10 } const obj2 = { count: 3, views: 20 } deepMerge([obj1, obj2], { customMerge: (key, target, source) => { // Add counts instead of replacing if (key === 'count') { return (target as number) + (source as number) } // Max for views if (key === 'views') { return Math.max(target as number, source as number) } return source // Default: source wins } }) // => { count: 8, views: 20 } ``` ### Real-World: Merge User Preferences Across Devices ```typescript const desktopPrefs = { theme: 'dark', editor: { fontSize: 14, tabSize: 2, fontFamily: 'Monaco' }, shortcuts: ['cmd+s', 'cmd+b'] } const mobilePrefs = { theme: 'light', // Override editor: { fontSize: 16, // Override lineNumbers: true // Add }, shortcuts: ['tap-save'] // Replace } const merged = deepMerge([desktopPrefs, mobilePrefs]) // { // theme: 'light', // editor: { // fontSize: 16, // tabSize: 2, // fontFamily: 'Monaco', // lineNumbers: true // }, // shortcuts: ['tap-save'] // } ``` ### Merging Multiple Objects ```typescript // Merge environment-specific configs const base = { /* base config */ } const development = { /* dev overrides */ } const local = { /* local overrides */ } // Later configs override earlier ones const config = deepMerge([base, development, local]) ``` --- ## Comparison Table | Function | Input | Output | Use Case | |----------|-------|--------|----------| | `flatten` | Nested object | Flat object | Database queries, URL params, logging | | `unflatten` | Flat object | Nested object | Env vars β†’ config, Redis β†’ object | | `rename` | Object + mapping | Renamed object | API normalization, schema migration | | `mask` | Object + allowlist | Filtered object | Security, logging, GDPR compliance | | `deepMerge` | Array of objects | Merged object | Config files, user preferences | --- ## When to Use Which ### Decision Tree ``` Need to transform object structure? β”‚ β”œβ”€ Nested β†’ Flat? β”‚ β†’ Use flatten() β”‚ β”œβ”€ Flat β†’ Nested? β”‚ β†’ Use unflatten() β”‚ β”œβ”€ Rename keys? β”‚ β”‚ β”‚ β”œβ”€ Few keys, known mapping? β”‚ β”‚ β†’ Use rename() β”‚ β”‚ β”‚ └─ All keys, pattern-based? β”‚ β†’ Use mapKeys() (see Transformation guide) β”‚ β”œβ”€ Remove sensitive fields? β”‚ β”‚ β”‚ β”œβ”€ Security/privacy concern? β”‚ β”‚ β†’ Use mask() (allowlist) β”‚ β”‚ β”‚ └─ Just want subset? β”‚ β†’ Use Remeda.pick() β”‚ └─ Merge multiple objects? β”‚ β”œβ”€ Shallow merge OK? β”‚ β†’ Use spread: { ...a, ...b } β”‚ └─ Need deep merge? β†’ Use deepMerge() ``` ### Common Patterns ```typescript // Pattern 1: API response β†’ DB schema pipe( apiResponse, (response) => flatten(response), rename(keyMapping), (flat) => unflatten(flat), validateShape(dbSchema) ) // Pattern 2: Sanitize for logging pipe( request, mask(safeFields), stripUndefined, (safe) => flatten(safe) ) // Pattern 3: Config merging pipe( [defaultConfig, envConfig, userConfig], deepMerge({ arrayStrategy: 'concat' }), validateShape(configSchema) ) // Pattern 4: Form data β†’ API payload pipe( formData, stripUndefined, rename(apiKeyMapping), mask(allowedFields) ) ``` ### Performance Considerations ```typescript // ⚠️ flatten/unflatten on large objects // For very deep/large objects, consider: flatten(obj, { maxDepth: 3 }) // Limit depth // βœ… mask is O(n) where n = number of keys // More efficient than filterKeys for known allowlist // βœ… deepMerge is optimized for typical configs // For huge objects with many levels, profile first // βœ… rename is O(n) - efficient for any size ``` --- ## Next Steps - **[Safe Access](./safe-access.mdx)** - getPath, setPath, updatePath for nested operations - **[Validation](./validation.mdx)** - validateShape, stripUndefined, compact - **[Transformation](./transformation.mdx)** - mapKeys, mapValues, filtering - **[Patterns & Recipes](./patterns.mdx)** - Complete real-world solutions --- # Migration Guide Step-by-step guide to migrating from Lodash or vanilla JavaScript object operations to Receta's Object module. ## Why Migrate? **Before (Lodash):** - Manual immutability with `cloneDeep` - No type safety - Not composable in pipelines - No Result/Option integration **After (Receta):** - Immutable by default - Full TypeScript support - Pipeline-friendly - Integrated error handling --- ## Migration Steps ### Step 1: Replace Deep Get/Set ```typescript // ❌ Lodash import _ from 'lodash' const value = _.get(obj, 'deeply.nested.value', 'default') _.set(_.cloneDeep(obj), 'deeply.nested.value', 'new') // βœ… Receta import { getPath, setPath } from '@ontologyio/receta/object' import { unwrapOr } from '@ontologyio/receta/option' const value = pipe( obj, getPath(['deeply', 'nested', 'value']), unwrapOr('default') ) const updated = setPath(['deeply', 'nested', 'value'], 'new', obj) ``` ### Step 2: Replace Pick/Omit with Mask ```typescript // ❌ Lodash const safe = _.pick(user, ['id', 'email']) // βœ… Receta (security-focused) const safe = mask(user, ['id', 'email']) ``` ### Step 3: Replace mapKeys/mapValues ```typescript // ❌ Lodash _.mapKeys(obj, (value, key) => key.toUpperCase()) _.mapValues(obj, (value) => value * 2) // βœ… Receta (mapKeys passes the key first) mapKeys(obj, (key, value) => key.toUpperCase()) mapValues(obj, (value) => value * 2) ``` ### Step 4: Replace Deep Merge ```typescript // ❌ Lodash _.merge(_.cloneDeep(obj1), obj2, obj3) // βœ… Receta deepMerge([obj1, obj2, obj3]) ``` ### Step 5: Add Validation ```typescript import { flatMap, fromResult } from '@ontologyio/receta/option' // ❌ Lodash (no validation) const config = _.get(data, 'config', {}) // Hope it's the right shape! // βœ… Receta (with validation) const config = pipe( data, getPath(['config']), flatMap((c) => fromResult(validateShape(c, configSchema))), unwrapOr(defaultConfig) ) ``` --- ## Common Patterns ### Pattern: Nested Updates ```typescript // ❌ Before const renameUser = (state: State) => { const updated = _.cloneDeep(state) _.set(updated, 'user.profile.name', 'Alice') return updated } // βœ… After const renameUser = (state: State) => setPath(['user', 'profile', 'name'], 'Alice', state) ``` ### Pattern: Safe Access ```typescript import { tap } from '@ontologyio/receta/option' // ❌ Before const email = _.get(user, 'profile.contact.email') if (email) { sendEmail(email) } // βœ… After pipe( user, getPath(['profile', 'contact', 'email']), tap(sendEmail) ) ``` ### Pattern: Transformation Pipeline ```typescript // ❌ Before let result = _.cloneDeep(apiResponse) result = _.mapKeys(result, (v, k) => _.camelCase(k)) result = _.omit(result, ['_internal', '_cache']) result = _.mapValues(result, (v) => typeof v === 'string' ? v.trim() : v) // βœ… After const result = pipe( apiResponse, filterKeys((key) => !key.startsWith('_')), transformKeys('camelCase', { deep: false }), mapValues((v) => typeof v === 'string' ? v.trim() : v) ) ``` --- ## Migration Checklist - [ ] Replace `_.get` β†’ `getPath` + `unwrapOr` - [ ] Replace `_.set` β†’ `setPath` (remove `cloneDeep`) - [ ] Replace `_.pick` β†’ `mask` (when security-focused) - [ ] Replace `_.mapKeys` β†’ `mapKeys` - [ ] Replace `_.mapValues` β†’ `mapValues` - [ ] Replace `_.merge` β†’ `deepMerge` (remove `cloneDeep`) - [ ] Add validation with `validateShape` - [ ] Add type annotations - [ ] Convert to pipelines where appropriate --- ## Performance Notes - No `cloneDeep` needed: only the objects along a path are copied - Tree-shakeable = smaller bundles - Type-safe = fewer runtime errors --- ## Next Steps - **[API Reference](../reference/object.mdx)** - Complete function reference - **[Patterns](./patterns.mdx)** - More real-world examples --- # Patterns & Recipes Copy-paste ready patterns for common real-world scenarios. All examples are production-ready and based on actual API structures. ## Table of Contents 1. [API Integration](#api-integration) 2. [Form Handling](#form-handling) 3. [Configuration Management](#configuration-management) 4. [Security & Sanitization](#security--sanitization) 5. [Database Operations](#database-operations) --- ## API Integration ### Pattern: GitHub API Normalization ```typescript import { map } from '@ontologyio/receta/result' import * as Obj from '@ontologyio/receta/object' import { pipe } from 'remeda' // GitHub repository response β†’ Your schema const normalizeGitHubRepo = (apiResponse: unknown) => pipe( apiResponse, Obj.validateShape({ name: (v: unknown): v is string => typeof v === 'string', full_name: (v: unknown): v is string => typeof v === 'string', owner: { login: (v: unknown): v is string => typeof v === 'string', avatar_url: (v: unknown): v is string => typeof v === 'string' }, stargazers_count: (v: unknown): v is number => typeof v === 'number', created_at: (v: unknown): v is string => typeof v === 'string' }), map(Obj.rename({ full_name: 'fullName', stargazers_count: 'stars', created_at: 'createdAt' })), map((repo) => Obj.flatten(repo)), map((flat) => Obj.rename(flat, { 'owner.login': 'ownerUsername', 'owner.avatar_url': 'ownerAvatar' })), map((renamed) => Obj.unflatten(renamed)) ) ``` ### Pattern: Stripe Payment Intent Processing ```typescript import { toResult } from '@ontologyio/receta/option' import { map as mapResult } from '@ontologyio/receta/result' const cardBrandKey = 'charges.data.0.payment_method_details.card.brand' // Stripe API β†’ Your database schema const processPaymentIntent = (stripeResponse: Obj.PlainObject) => pipe( stripeResponse, Obj.getPath(['id']), toResult({ code: 'MISSING_ID' }), mapResult(() => pipe( Obj.flatten(stripeResponse, { flattenArrays: true }), Obj.filterKeys((key) => ['id', 'amount', 'currency', 'status', 'customer.email', cardBrandKey].some( (allowed) => key.startsWith(allowed) ) ), Obj.rename({ 'customer.email': 'customerEmail', [cardBrandKey]: 'cardBrand' }) ) ) ) ``` --- ## Form Handling ### Pattern: React Form State Manager ```typescript import { type Option } from '@ontologyio/receta/option' import { type Result } from '@ontologyio/receta/result' interface FormState { data: T errors: Record touched: Record } class Form { constructor(private state: FormState) {} // Get field value getValue(path: Obj.ObjectPath): Option { return Obj.getPath(path, this.state.data) } // Update field updateField(path: Obj.ObjectPath, value: unknown): Form { return new Form({ ...this.state, data: Obj.setPath(path, value, this.state.data), touched: Obj.setPath(path, true, this.state.touched) }) } // Validate and submit submit(schema: Obj.ObjectSchema): Result { return Obj.validateShape(Obj.compact(this.state.data), schema) } } // Usage const userForm = new Form({ data: { name: '', email: '', age: undefined }, errors: {}, touched: {} }) userForm .updateField(['name'], 'Alice') .updateField(['email'], 'alice@example.com') .submit(userSchema) ``` ### Pattern: Multi-Step Form with Partial Validation ```typescript import { flatMap } from '@ontologyio/receta/result' const multiStepForm = { // Step 1: Personal Info validateStep1: (data: Obj.PlainObject) => pipe( data, Obj.mask(['name', 'email', 'phone']), Obj.stripUndefined, (partial) => Obj.validateShape(partial, step1Schema) ), // Step 2: Address validateStep2: (data: Obj.PlainObject) => pipe( data, Obj.mask(['street', 'city', 'state', 'zip']), Obj.stripUndefined, (partial) => Obj.validateShape(partial, step2Schema) ), // Final: Combine and validate all submitAll: (formData: Obj.PlainObject) => pipe( formData, Obj.compact, (data) => Obj.validateShape(data, fullSchema), flatMap(submitToAPI) ) } ``` --- ## Configuration Management ### Pattern: Environment-Specific Config Merge ```typescript import { unwrapOr } from '@ontologyio/receta/result' // Next.js / Vite style config merging const loadConfig = () => { const base = { api: { url: 'https://api.example.com', timeout: 30000, retries: 3 }, features: { analytics: false, debugMode: false } } const envConfig = pipe( process.env, Obj.filterKeys((key) => key.startsWith('APP_')), Obj.mapKeys((key) => key.replace(/^APP_/, '').toLowerCase()), (flat) => Obj.unflatten(flat, { separator: '__' }) ) const userConfig = loadUserConfig() // From file return pipe( [base, envConfig, userConfig], Obj.deepMerge({ arrayStrategy: 'concat' }), (merged) => Obj.validateShape(merged, configSchema), unwrapOr(base) ) } ``` ### Pattern: Feature Flags with Fallbacks ```typescript import { map, unwrapOr } from '@ontologyio/receta/option' const getFeatureFlags = (remoteConfig: Obj.PlainObject) => { const defaults = { newDashboard: false, betaFeatures: false, experimentalApi: false } return pipe( remoteConfig, Obj.getPath>(['features']), map(Obj.compact), map((flags) => ({ ...defaults, ...flags })), unwrapOr(defaults) ) } ``` --- ## Security & Sanitization ### Pattern: Sanitize Before Logging ```typescript const sanitizeForLogging = ( obj: Obj.PlainObject, sensitiveFields: string[] = ['password', 'apiKey', 'creditCard', 'ssn'] ) => pipe( Obj.flatten(obj, { flattenArrays: true }), Obj.filterKeys((key) => !sensitiveFields.some((sensitive) => key.toLowerCase().includes(sensitive.toLowerCase())) ), (safe) => Obj.unflatten(safe) ) // Usage logger.info('User login', sanitizeForLogging(request)) // Removes every key that contains a sensitive name, at any depth ``` ### Pattern: GDPR-Compliant Data Minimization ```typescript const gdprMinimize = { // Only collect necessary analytics analytics: (event: UserEvent) => pipe( event, Obj.mask(['action', 'timestamp', 'category']), Obj.stripUndefined // No PII stored ), // Anonymize user data for support supportTicket: (user: User) => pipe( user, Obj.mask(['id', 'createdAt', 'subscription']), Obj.updatePath(['id'], (id: string) => hashUserId(id)) // Email/name removed, ID hashed ), // Export user data (GDPR right to access) exportUserData: (userId: string) => fetchAllUserData(userId).then((data) => pipe( Obj.flatten(data), Obj.filterKeys((key) => !key.includes('_internal')), (kept) => Obj.unflatten(kept) ) ) } ``` ### Pattern: API Response Filtering by Role ```typescript const filterByRole = (user: User, role: UserRole) => { const publicFields = ['id', 'username', 'avatar'] const authenticatedFields = [...publicFields, 'email', 'createdAt'] const adminFields = [...authenticatedFields, 'lastLogin', 'ipAddress', 'internalNotes'] const allowedFields = { public: publicFields, user: authenticatedFields, admin: adminFields }[role] return Obj.mask(user, allowedFields) } ``` --- ## Database Operations ### Pattern: Query Builder ```typescript // A sketch of the data flow. Use parameterized queries with real user input. const buildWhereClause = (filters: Record) => pipe( filters, Obj.compact, (active) => Obj.flatten(active), Obj.mapKeys((key) => `table.${key}`), Obj.mapValues((value) => Array.isArray(value) ? `IN (${value.join(',')})` : `= '${value}'` ), Object.entries, (entries) => entries.map(([key, value]) => `${key} ${value}`).join(' AND ') ) // Usage const sql = `SELECT * FROM users WHERE ${buildWhereClause({ status: 'active', role: ['admin', 'moderator'], 'profile.verified': true })}` // => "SELECT * FROM users WHERE table.status = 'active' AND table.role IN (admin,moderator) AND table.profile.verified = 'true'" ``` ### Pattern: Prisma-Style Nested Updates ```typescript const updateNestedUser = (userId: string, updates: DeepPartial) => pipe( updates, (partial) => Obj.flatten(partial), Obj.filterKeys((key) => !key.includes('id')), // Protect ID fields Obj.stripUndefined, (flat) => Object.entries(flat).reduce( (query, [path, value]) => query.update({ where: { id: userId }, data: { [path]: value } }), prisma.user ) ) ``` --- ## Complete Real-World Example: Payment Processing ```typescript import { isErr, isOk, mapErr, tapErr } from '@ontologyio/receta/result' // Complete payment processing pipeline const processPayment = async (stripeWebhook: unknown) => { // 1. Validate webhook structure const validated = pipe( stripeWebhook, Obj.validateShape(stripeWebhookSchema), mapErr((error) => ({ code: 'INVALID_WEBHOOK', message: 'Webhook validation failed', details: error })) ) if (isErr(validated)) { logger.error('Invalid webhook', validated.error) return validated } // 2. Extract payment data const paymentData = pipe( Obj.flatten(validated.value), Obj.filterKeys((key) => ['data.object.id', 'data.object.amount', 'data.object.customer'].some((prefix) => key.startsWith(prefix) ) ), Obj.rename({ 'data.object.id': 'paymentId', 'data.object.amount': 'amount', 'data.object.customer': 'customerId' }), Obj.compact ) // 3. Update database. flatMap can't await, so the async save sits outside the pipe. const payment = Obj.validateShape(paymentData, dbPaymentSchema) const dbResult = isOk(payment) ? await savePaymentToDb(payment.value) : payment tapErr(dbResult, (error) => logger.error('DB save failed', { paymentData: sanitizeForLogging(paymentData), error }) ) // 4. Send notification if (isOk(dbResult)) { await sendPaymentConfirmation(dbResult.value.customerId) } return dbResult } ``` --- ## Next Steps - **[Migration Guide](./migration.mdx)** - Migrate from Lodash/Vanilla JS - **[API Reference](../reference/object.mdx)** - Quick lookup and cheat sheets --- # Safe Access Safe access functions provide type-safe ways to read and modify deeply nested object structures without runtime errors. All operations are immutable and integrate with the Option pattern. ## Table of Contents 1. [getPath](#getpath) 2. [setPath](#setpath) 3. [updatePath](#updatepath) 4. [Comparison with Alternatives](#comparison-with-alternatives) 5. [Common Patterns](#common-patterns) --- ## getPath Safely retrieves a value at a nested path, returning `Option` instead of throwing errors. ### Signature ```typescript title="Signature" function getPath(path: ObjectPath, obj: PlainObject): Option function getPath(path: ObjectPath): (obj: PlainObject) => Option type ObjectPath = readonly (string | number)[] ``` `getPath`, `setPath` and `updatePath` take the path first and the object last, the reverse of `mask` or `rename`. Leave the object out to get a function for `pipe`. ### Why getPath Over Optional Chaining? ```typescript import { getPath } from '@ontologyio/receta/object' import { unwrapOr } from '@ontologyio/receta/option' import { pipe } from 'remeda' const config: { api?: { timeout?: number } } = {} // Optional chaining: the path is fixed in the source and you get a bare value const fixedTimeout = config.api?.timeout ?? 5000 // => 5000 // getPath: the path is a value you can pass around, and the Option composes const timeout = pipe( config, getPath(['api', 'timeout']), unwrapOr(5000) ) // => 5000 ``` ### Real-World: Configuration Loading ```typescript // Load database config with fallbacks const loadDbConfig = (config: PlainObject) => { const host = pipe( config, getPath(['database', 'host']), unwrapOr('localhost') ) const port = pipe( config, getPath(['database', 'port']), unwrapOr(5432) ) const ssl = pipe( config, getPath(['database', 'ssl']), unwrapOr(false) ) return { host, port, ssl } } ``` ### Real-World: API Response Extraction ```typescript import { match, toResult } from '@ontologyio/receta/option' // GitHub API: extract nested data safely const getRepoStars = (apiResponse: PlainObject) => pipe( apiResponse, getPath(['repository', 'stargazers_count']), match({ onSome: (stars) => `${stars} stars`, onNone: () => 'No star data' }) ) // Stripe API: extract nested customer email const getCustomerEmail = (charge: PlainObject) => pipe( charge, getPath(['customer', 'email']), toResult({ code: 'MISSING_EMAIL', message: 'Customer email not found' }) ) ``` ### Array Indices ```typescript const data = { users: [ { name: 'Alice', email: 'alice@example.com' }, { name: 'Bob', email: 'bob@example.com' } ] } // Access array elements by index const firstUserEmail = getPath(['users', 0, 'email'], data) // => Some('alice@example.com') const missingUser = getPath(['users', 99, 'email'], data) // => None ``` ### Composing with Option Operations ```typescript import { map, flatMap, filter } from '@ontologyio/receta/option' // Extract and transform nested value const getUserAge = (data: PlainObject) => pipe( data, getPath(['user', 'profile', 'age']), filter(age => age >= 18), // Only adults map(age => `${age} years old`) ) // Some('30 years old') for an adult, None otherwise ``` ### Type Safety `getPath` can't work out the value's type from the path. The type argument is your claim about the value and nothing checks it at runtime. Without one you get `Option`. Describe your data with `type`, not `interface`. These functions take `PlainObject`, which is `Record`, and TypeScript won't accept an interface there. ```typescript type Config = { database: { host: string port: number } } const config: Config = { database: { host: 'localhost', port: 5432 } } // Option const host = getPath(['database', 'host'], config) // => Some('localhost') // Option const port = getPath(['database', 'port'], config) // => Some(5432) ``` --- ## setPath Immutably sets a value at a nested path, creating intermediate objects as needed. ### Signature ```typescript title="Signature" function setPath(path: ObjectPath, value: unknown, obj: T): T function setPath(path: ObjectPath, value: unknown): (obj: T) => T ``` ### Why setPath Over Mutation? ```typescript // ❌ Mutation: side effects, hard to track function updateConfig(config) { config.database.host = 'prod.example.com' return config // Original mutated! } // βœ… setPath: immutable, predictable const updated = setPath(['database', 'host'], 'prod.example.com', config) // Original unchanged, new object returned ``` ### Real-World: Immutable State Updates (Redux-style) ```typescript // Redux reducer pattern const reducer = (state: AppState, action: Action) => { switch (action.type) { case 'UPDATE_USERNAME': return setPath(['user', 'profile', 'username'], action.payload, state) case 'INCREMENT_VIEWS': return updatePath(['stats', 'views'], (n: number) => n + 1, state) case 'SET_THEME': return setPath(['ui', 'theme'], action.payload, state) default: return state } } ``` ### Real-World: Form Field Updates ```tsx // React form state management const FormComponent = () => { const [formData, setFormData] = useState(initialData) const updateField = (path: ObjectPath, value: unknown) => { setFormData(prev => setPath(path, value, prev)) } const fieldValue = (path: ObjectPath) => unwrapOr(getPath(path, formData), '') return ( <> updateField(['user', 'firstName'], e.target.value)} /> updateField(['user', 'address', 'city'], e.target.value)} /> ) } ``` ### Creating Intermediate Paths ```typescript // setPath creates missing intermediate objects const empty = {} const result = setPath(['api', 'endpoints', 'users'], '/api/v1/users', empty) // => { api: { endpoints: { users: '/api/v1/users' } } } ``` ### Working with Arrays ```typescript const data = { items: ['apple', 'banana', 'cherry'] } // Update array element const updated = setPath(['items', 1], 'blueberry', data) // => { items: ['apple', 'blueberry', 'cherry'] } // A number in the path creates an array const nested = setPath(['users', 0, 'name'], 'Alice', {}) // => { users: [{ name: 'Alice' }] } ``` ### Chaining Multiple Updates ```typescript // Multiple immutable updates in sequence const result = pipe( config, setPath(['database', 'host'], 'prod.db.com'), setPath(['database', 'port'], 5432), setPath(['api', 'timeout'], 30000) ) ``` --- ## updatePath Updates a value at a path by applying a transformation function. ### Signature ```typescript title="Signature" function updatePath(path: ObjectPath, fn: (value: V) => V, obj: T): T function updatePath(path: ObjectPath, fn: (value: V) => V): (obj: T) => T ``` ### Why updatePath? ```typescript // ❌ Without updatePath: verbose and error-prone const incrementViews = (state: AppState) => { const currentViews = getPath(['stats', 'views'], state) if (isSome(currentViews)) { return setPath(['stats', 'views'], currentViews.value + 1, state) } return state } // βœ… With updatePath: concise and safe const incrementViews = (state: AppState) => updatePath(['stats', 'views'], (n: number) => n + 1, state) ``` ### Real-World: Analytics Tracking ```typescript // Increment view counters const trackPageView = (analytics: Analytics, pageId: string) => updatePath( ['pages', pageId, 'views'], (views: number) => views + 1, analytics ) // Update last seen timestamp const updateLastSeen = (user: User) => updatePath(['activity', 'lastSeen'], () => Date.now(), user) ``` ### Real-World: Shopping Cart Operations ```typescript type CartItem = { id: string quantity: number price: number } type Cart = { items: CartItem[] total: number } // Increment item quantity const incrementQuantity = (cart: Cart, itemId: string) => { const itemIndex = cart.items.findIndex(item => item.id === itemId) return pipe( cart, updatePath(['items', itemIndex, 'quantity'], (q: number) => q + 1), (c) => updatePath(['total'], (total: number) => recalculateTotal(c), c) ) } ``` ### Real-World: Nested Array Updates ```typescript // Update specific todo in nested structure const toggleTodo = (state: AppState, listId: string, todoIndex: number) => updatePath( ['lists', listId, 'todos', todoIndex, 'completed'], (completed: boolean) => !completed, state ) ``` ### Transforming Complex Values ```typescript // Append to array updatePath(['tags'], (tags: string[]) => [...tags, 'new-tag'], state) // Transform nested object updatePath(['user', 'preferences'], (prefs: Prefs) => ({ ...prefs, notifications: true }), state) // String manipulation updatePath(['profile', 'bio'], (bio: string) => bio.trim().slice(0, 280), user) ``` ### Path Not Found Behavior ```typescript // If path doesn't exist, returns original object unchanged const state = { user: { name: 'Alice' } } const result = updatePath(['user', 'age'], (age: number) => age + 1, state) // => { user: { name: 'Alice' } } // To set instead of update when path might not exist: const ensureAge = (state: State) => { const updated = updatePath(['user', 'age'], (age: number) => age + 1, state) return updated === state ? setPath(['user', 'age'], 1, state) // Initialize : updated } ``` --- ## Comparison with Alternatives ### vs Optional Chaining (`?.`) ```typescript // Optional chaining const value = obj?.deeply?.nested?.value ?? 'default' // Pros: Native, concise for simple cases // Cons: Path fixed in the source, nothing to chain on the result // getPath const value = pipe( obj, getPath(['deeply', 'nested', 'value']), unwrapOr('default') ) // Pros: Composable, paths can be data, works in pipelines // Cons: More verbose for simple cases ``` ### vs Lodash get/set ```typescript // Lodash get _.get(obj, 'deeply.nested.value', 'default') // Lodash set (MUTATES!) _.set(obj, 'deeply.nested.value', 'new') // Lodash setWith (immutable, but requires cloneDeep) _.setWith(_.cloneDeep(obj), 'deeply.nested.value', 'new') // Receta: immutable by default, type-safe setPath(['deeply', 'nested', 'value'], 'new', obj) ``` ### vs Immer ```typescript // Immer produce(obj, draft => { draft.deeply.nested.value = 'new' }) // Pros: Mutation-like syntax // Cons: Magic, runtime overhead, not composable // Receta setPath(['deeply', 'nested', 'value'], 'new', obj) // Pros: Explicit, composable, no magic // Cons: More verbose for multiple updates ``` ### Comparison Table | Feature | Optional Chain | Lodash | Immer | Receta | |---------|---------------|--------|-------|---------| | Immutable | βœ… | ❌ | βœ… | βœ… | | Type-safe | ⚠️ Partial | ❌ | ⚠️ Partial | βœ… | | Composable | ❌ | ❌ | ❌ | βœ… | | Returns Option/Result | ❌ | ❌ | ❌ | βœ… | | Pipeline-friendly | ❌ | ❌ | ❌ | βœ… | | Zero overhead | βœ… | βœ… | ❌ | βœ… | --- ## Common Patterns ### Pattern 1: Safe Config Access with Fallbacks ```typescript const getAppConfig = (rawConfig: PlainObject): AppConfig => ({ apiUrl: pipe( rawConfig, getPath(['api', 'url']), unwrapOr('https://api.example.com') ), timeout: pipe( rawConfig, getPath(['api', 'timeout']), map(ms => Math.min(ms, 60000)), // Cap at 60s unwrapOr(30000) ), retries: pipe( rawConfig, getPath(['api', 'retries']), filter(n => n >= 0 && n <= 5), // Validate range unwrapOr(3) ) }) ``` ### Pattern 2: Nested Form State Management ```typescript // Complete form state manager class FormState { constructor(private data: T) {} get(path: ObjectPath): Option { return getPath(path, this.data) } set(path: ObjectPath, value: unknown): FormState { return new FormState(setPath(path, value, this.data)) } update(path: ObjectPath, fn: (value: V) => V): FormState { return new FormState(updatePath(path, fn, this.data)) } validate(path: ObjectPath, validator: (value: unknown) => boolean): boolean { return pipe( this.data, getPath(path), map(validator), unwrapOr(false) ) } } ``` ### Pattern 3: Composing get/set/update ```typescript // Get current value, transform, set back const capitalizeUserName = (state: State) => pipe( state, getPath(['user', 'name']), map(name => name.toUpperCase()), match({ onSome: (upper) => setPath(['user', 'name'], upper, state), onNone: () => state }) ) // Simpler with updatePath const capitalizeUserName = (state: State) => updatePath(['user', 'name'], (name: string) => name.toUpperCase(), state) ``` ### Pattern 4: Bulk Updates ```typescript // Update multiple paths const updateUserProfile = ( user: User, updates: { name?: string; email?: string; bio?: string } ) => { let result = user if (updates.name) { result = setPath(['profile', 'name'], updates.name, result) } if (updates.email) { result = setPath(['contact', 'email'], updates.email, result) } if (updates.bio) { result = setPath(['profile', 'bio'], updates.bio, result) } return result } ``` ### Pattern 5: Conditional Updates ```typescript // updatePath already skips a missing path, so no check is needed const incrementIfExists = (state: State, path: ObjectPath) => updatePath(path, (n: number) => n + 1, state) ``` ### Pattern 6: API Response Extraction Pipeline ```typescript import { collect, map as mapResult } from '@ontologyio/receta/result' // Extract multiple values from API response const extractUserData = (apiResponse: PlainObject) => { const userId = pipe( apiResponse, getPath(['data', 'user', 'id']), toResult({ code: 'MISSING_USER_ID' }) ) const email = pipe( apiResponse, getPath(['data', 'user', 'email']), toResult({ code: 'MISSING_EMAIL' }) ) const name = pipe( apiResponse, getPath(['data', 'user', 'profile', 'displayName']), unwrapOr('Anonymous') ) // Combine results return pipe( collect([userId, email]), mapResult(([id, email]) => ({ id, email, name })) ) } ``` --- ## Next Steps - **[Validation](./validation.mdx)** - validateShape, stripUndefined, compact - **[Transformation](./transformation.mdx)** - mapKeys, mapValues, filtering - **[Patterns & Recipes](./patterns.mdx)** - Complete solutions combining all operations - **[API Reference](../reference/object.mdx)** - Quick lookup and cheat sheets --- # Transformation Transformation functions allow you to systematically transform or filter object keys and values. These operations are essential for normalizing data, applying bulk transformations, and filtering unwanted properties. ## Table of Contents 1. [mapKeys](#mapkeys) 2. [mapValues](#mapvalues) 3. [filterKeys](#filterkeys) 4. [filterValues](#filtervalues) 5. [Comparison](#comparison) --- ## mapKeys Transform all keys in an object using a mapping function. ```typescript title="Signature" function mapKeys( obj: T, fn: (key: string, value: T[keyof T]) => string ): PlainObject ``` ### Real-World: snake_case β†’ camelCase ```typescript // API response normalization const apiResponse = { user_id: 123, first_name: 'Alice', last_name: 'Smith', email_address: 'alice@example.com', created_at: '2024-01-01' } const normalized = mapKeys(apiResponse, (key) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) ) // { userId: 123, firstName: 'Alice', lastName: 'Smith', ... } ``` For the common conventions, `transformKeys(apiResponse, 'camelCase')` does this for you, nested objects included. ### Real-World: Prefixing for Namespacing ```typescript // Add namespace to avoid conflicts const config = { host: 'localhost', port: 5432 } const namespaced = mapKeys(config, (key) => `db_${key}`) // => { db_host: 'localhost', db_port: 5432 } ``` ### Conditional Transformation ```typescript // Transform only numeric values' keys mapKeys(data, (key, value) => typeof value === 'number' ? key.toUpperCase() : key ) ``` --- ## mapValues Transform all values in an object using a mapping function. ```typescript title="Signature" function mapValues( obj: T, fn: (value: T[keyof T], key: keyof T) => U ): Record ``` ### Real-World: Price Calculations ```typescript const prices = { apple: 1.50, banana: 0.50, orange: 2.00 } // Apply 10% tax const withTax = mapValues(prices, (price) => Math.round(price * 1.1 * 100) / 100 ) // => { apple: 1.65, banana: 0.55, orange: 2.2 } ``` ### Real-World: Type Conversions ```typescript // Convert all values to strings const config = { port: 5432, timeout: 30000, retries: 3 } const envVars = mapValues(config, (v) => String(v)) // => { port: '5432', timeout: '30000', retries: '3' } ``` ### Access Key in Transformation ```typescript const user = { name: 'Alice', age: 30 } // Format with key context mapValues(user, (value, key) => `${String(key)}: ${value}`) // => { name: 'name: Alice', age: 'age: 30' } ``` --- ## filterKeys Filter object by keeping only keys that match a predicate. ```typescript title="Signature" function filterKeys( obj: T, predicate: (key: string) => boolean ): Partial ``` ### Real-World: Remove Private Fields ```typescript const obj = { id: 1, name: 'Alice', _internal: 'hidden', _cache: {}, email: 'alice@example.com' } const visible = filterKeys(obj, (key) => !key.startsWith('_')) // => { id: 1, name: 'Alice', email: 'alice@example.com' } ``` ### Real-World: Extract by Pattern ```typescript // Get only API-related config const config = { api_key: 'secret', api_url: 'https://api.com', api_timeout: 30000, db_host: 'localhost', db_port: 5432 } const apiConfig = filterKeys(config, (key) => key.startsWith('api_')) // => { api_key: 'secret', api_url: 'https://api.com', api_timeout: 30000 } ``` --- ## filterValues Filter object by keeping only entries whose values match a predicate. ```typescript title="Signature" function filterValues( obj: T, predicate: (value: T[keyof T], key: keyof T) => boolean ): Partial ``` ### Real-World: Filter by Score Threshold ```typescript const scores = { alice: 95, bob: 65, charlie: 88, diana: 72 } const passing = filterValues(scores, (score) => score >= 70) // => { alice: 95, charlie: 88, diana: 72 } ``` ### Real-World: Remove Empty Values ```typescript const formData = { name: 'Alice', email: '', phone: '', company: 'Acme Inc' } const filled = filterValues(formData, (v) => v !== '') // => { name: 'Alice', company: 'Acme Inc' } ``` ### With Key Context ```typescript // Filter based on key-value relationship filterValues(inventory, (qty, product) => String(product).startsWith('premium_') ? qty > 10 : qty > 0 ) ``` --- ## Comparison | Function | Transforms | Use Case | |----------|-----------|----------| | `mapKeys` | All keys | Custom renames, prefixing | | `transformKeys` | All keys, at every depth | Normalize naming (camelCase, snake_case) | | `mapValues` | All values | Calculations, type conversions | | `filterKeys` | Keys (keep matching) | Remove private/internal fields | | `filterValues` | Values (keep matching) | Threshold filtering, remove empty | ### Combined Example ```typescript // Complete transformation pipeline const process = (apiData: PlainObject) => pipe( apiData, filterKeys((key) => !key.startsWith('_')), // Remove private transformKeys('camelCase'), // Normalize keys mapValues((v) => typeof v === 'string' ? v.trim() : v), // Trim strings filterValues((v) => v != null), // Remove nullish compact // Final cleanup ) ``` --- ## Next Steps - **[Patterns & Recipes](./patterns.mdx)** - Real-world solutions - **[Migration Guide](./migration.mdx)** - From Lodash to Receta - **[API Reference](../reference/object.mdx)** - Quick lookup --- # Validation Validation functions ensure data integrity through runtime type checking and cleanup operations. These utilities help you validate unknown data, remove unwanted values, and ensure objects meet expected schemas. ## Table of Contents 1. [validateShape](#validateshape) 2. [stripUndefined](#stripundefined) 3. [compact](#compact) 4. [When to Use Which](#when-to-use-which) 5. [Integration with Validation Module](#integration-with-validation-module) --- ## validateShape Runtime validation that checks if an object conforms to a schema, returning `Result`. ### Signature ```typescript title="Signature" function validateShape( obj: unknown, schema: ObjectSchema ): Result type ObjectSchema = { readonly [K in keyof T]: Validator | ObjectSchema } type Validator = (value: unknown) => value is T interface ObjectError { readonly code: 'PATH_NOT_FOUND' | 'INVALID_PATH' | 'VALIDATION_ERROR' | 'CIRCULAR_REFERENCE' readonly message: string readonly path?: ObjectPath readonly cause?: unknown } ``` `validateShape` stops at the first failure, and the error it returns always has the code `VALIDATION_ERROR`. ### Why validateShape? ```typescript // ❌ Without validation: runtime errors function processConfig(config: unknown) { const host = config.database.host // TypeError if config.database doesn't exist! const port = config.database.port // TypeError! return { host, port } } // βœ… With validateShape: safe, typed const configSchema = { database: { host: (v: unknown): v is string => typeof v === 'string', port: (v: unknown): v is number => typeof v === 'number' } } const result = validateShape(config, configSchema) if (isOk(result)) { // Fields with a validator get its type. A nested schema's type is unknown, // so pass a type argument to validateShape when you need it. const { database } = result.value console.log(database) } ``` ### Real-World: API Response Validation ```typescript import { map } from '@ontologyio/receta/result' // GitHub API response validation const githubUserSchema = { login: (v: unknown): v is string => typeof v === 'string', id: (v: unknown): v is number => typeof v === 'number', avatar_url: (v: unknown): v is string => typeof v === 'string' && v.startsWith('http'), public_repos: (v: unknown): v is number => typeof v === 'number' && v >= 0 } const validateGitHubUser = (response: unknown) => pipe( response, (r) => validateShape(r, githubUserSchema), map(user => ({ username: user.login, id: user.id, avatar: user.avatar_url, repos: user.public_repos })) ) ``` ### Real-World: Environment Variable Validation ```typescript const envSchema = { NODE_ENV: (v: unknown): v is string => ['development', 'production', 'test'].includes(v as string), PORT: (v: unknown): v is string => typeof v === 'string' && /^\d+$/.test(v), DATABASE_URL: (v: unknown): v is string => typeof v === 'string' && v.length > 0, API_KEY: (v: unknown): v is string => typeof v === 'string' && v.length === 32 } const loadEnv = () => { const result = validateShape(process.env, envSchema) return match(result, { onOk: (env) => env, onErr: (error) => { console.error(`Invalid environment: ${error.message}`, error.path) process.exit(1) } }) } ``` ### Nested Schema Validation ```typescript // Validate nested structures const userSchema = { id: (v: unknown): v is number => typeof v === 'number', profile: { name: (v: unknown): v is string => typeof v === 'string' && v.length > 0, email: (v: unknown): v is string => typeof v === 'string' && v.includes('@'), age: (v: unknown): v is number => typeof v === 'number' && v >= 0 && v <= 150 }, settings: { theme: (v: unknown): v is string => ['light', 'dark'].includes(v as string), notifications: (v: unknown): v is boolean => typeof v === 'boolean' } } const result = validateShape(unknownData, userSchema) // Returns Err with path if nested validation fails: // { code: 'VALIDATION_ERROR', path: ['profile', 'email'], message: 'Validation failed for key "email"' } ``` ### Integration with Predicate Module ```typescript import { isString, isNumber } from '@ontologyio/receta/predicate' const isEmail = (v: unknown): v is string => isString(v) && v.includes('@') const userSchema = { name: isString, email: isEmail, age: isNumber } validateShape(data, userSchema) ``` ### Custom Validators ```typescript // Build reusable validators const isPositiveInt = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v > 0 const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 const isValidUrl = (v: unknown): v is string => typeof v === 'string' && /^https?:\/\/.+/.test(v) const apiSchema = { endpoint: isValidUrl, timeout: isPositiveInt, apiKey: isNonEmptyString } ``` ### Error Handling ```typescript const handleValidationError = (error: ObjectError) => `Validation failed at ${error.path?.join('.')}: ${error.message}` const result = validateShape(data, userSchema) if (isErr(result)) { console.error(handleValidationError(result.error)) } ``` --- ## stripUndefined Removes all properties with `undefined` values from an object (shallow operation). ### Signature ```typescript title="Signature" function stripUndefined(obj: T): Partial ``` ### Why stripUndefined? ```typescript // ❌ An undefined field is still a key: spreads and Object.keys see it const payload = { name: 'Alice', email: undefined, phone: undefined } Object.keys(payload) // => ['name', 'email', 'phone'] // βœ… Clean payload before sending const cleanPayload = stripUndefined(payload) // => { name: 'Alice' } ``` ### Real-World: Form Data Sanitization ```typescript import { flatMap } from '@ontologyio/receta/result' // React form with optional fields const FormComponent = () => { const [formData, setFormData] = useState({ name: 'Alice', email: 'alice@example.com', phone: undefined, // Optional field not filled company: undefined // Optional field not filled }) const handleSubmit = async () => { const cleanData = pipe( formData, stripUndefined, (data) => validateShape(data, formSchema), flatMap(saveToApi) ) } } ``` ### Real-World: Query String Building ```typescript // Build URL query params, omit undefined const buildQueryString = (params: Record) => { const clean = stripUndefined(params) return new URLSearchParams(clean as Record).toString() } const url = buildQueryString({ search: 'receta', category: undefined, // Not set, should be omitted page: 1, sortBy: undefined // Not set, should be omitted }) // => "search=receta&page=1" ``` ### Real-World: Partial Updates ```typescript // Only send changed fields to API const updateUser = (userId: string, changes: Partial) => { const payload = stripUndefined(changes) // Only sends defined fields return fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }) } // Usage updateUser('123', { name: 'Alice Smith', // Changed email: undefined, // Not changed, omit age: undefined // Not changed, omit }) ``` ### Keeps Other Falsy Values ```typescript const data = { count: 0, // Kept (falsy but not undefined) active: false, // Kept (falsy but not undefined) name: '', // Kept (falsy but not undefined) email: null, // Kept (not undefined) phone: undefined // Removed } stripUndefined(data) // => { count: 0, active: false, name: '', email: null } ``` ### In Pipelines ```typescript // Complete sanitization pipeline const sanitizeFormData = (formData: Record) => pipe( formData, stripUndefined, // Remove undefined filterValues((v) => v !== ''), // Remove empty strings mask(allowedFields), // Only allowed fields validateShape(formSchema) // Validate structure ) ``` --- ## compact Removes all nullish (`null` or `undefined`) values from an object (shallow operation). ### Signature ```typescript title="Signature" function compact(obj: T): Partial ``` ### Why compact? ```typescript // ❌ Nullish values can cause issues downstream const userData = { name: 'Alice', email: null, // User cleared email phone: undefined, // Never provided age: 30 } // βœ… Remove all nullish values const clean = compact(userData) // => { name: 'Alice', age: 30 } ``` ### stripUndefined vs compact ```typescript const data = { a: undefined, b: null, c: 0, d: false, e: '' } // Keeps null stripUndefined(data) // => { b: null, c: 0, d: false, e: '' } // Removes both null and undefined compact(data) // => { c: 0, d: false, e: '' } ``` ### Real-World: Database Query Building ```typescript // Build WHERE clause, skip nullish filters const buildWhereClause = (filters: { status?: string | null category?: string | null minPrice?: number | null }) => { const activeFilters = compact(filters) return Object.entries(activeFilters) .map(([key, value]) => `${key} = '${value}'`) .join(' AND ') } buildWhereClause({ status: 'active', category: null, // Skip this minPrice: undefined // Skip this }) // => "status = 'active'" ``` ### Real-World: GraphQL Query Variables ```typescript // Remove nullish variables before sending GraphQL query const queryUsers = (filters: UserFilters) => { const variables = compact(filters) return graphql` query Users($status: String, $role: String, $search: String) { users(status: $status, role: $role, search: $search) { id name } } `({ variables // Only includes non-nullish values }) } ``` ### Real-World: localStorage Cleanup ```typescript // Save only meaningful preferences const savePreferences = (prefs: UserPreferences) => { const meaningful = pipe( prefs, compact, // Remove nullish filterValues((v) => v !== '') // Remove empty strings ) localStorage.setItem('preferences', JSON.stringify(meaningful)) } ``` ### Preserves Zero, False, Empty String ```typescript // compact only removes nullish, not other falsy values const data = { count: 0, // βœ… Kept (valid number) enabled: false, // βœ… Kept (valid boolean) title: '', // βœ… Kept (valid string) notes: null, // ❌ Removed (nullish) tags: undefined // ❌ Removed (nullish) } compact(data) // => { count: 0, enabled: false, title: '' } ``` ### In Pipelines ```typescript // Complete data cleanup const prepareAnalytics = (rawData: RawAnalytics) => pipe( rawData, compact, // Remove nullish (data) => flatten(data), // Flatten for storage filterKeys((k) => !k.includes('_internal')) // Remove internal keys ) ``` --- ## When to Use Which ### Decision Tree ``` Need to validate or clean object? β”‚ β”œβ”€ Need runtime type checking? β”‚ β†’ Use validateShape() β†’ Result β”‚ β”œβ”€ Have optional form fields? β”‚ β”‚ β”‚ β”œβ”€ Only remove undefined? β”‚ β”‚ β†’ Use stripUndefined() β”‚ β”‚ β”‚ └─ Remove both null and undefined? β”‚ β†’ Use compact() β”‚ └─ Building API payload? └─ Use stripUndefined() + validateShape() ``` ### Comparison Table | Function | Removes | Returns | Use Case | |----------|---------|---------|----------| | `validateShape` | - | `Result` | Type checking unknown data | | `stripUndefined` | `undefined` | `Partial` | Form data, API payloads | | `compact` | `null` + `undefined` | `Partial` | Query builders, cleanup | ### Common Patterns ```typescript import { flatMap, map, unwrapOr } from '@ontologyio/receta/result' // Pattern 1: Form submission pipe( formData, stripUndefined, validateShape(formSchema), flatMap(submitToApi) ) // Pattern 2: Query building pipe( filters, compact, buildWhereClause ) // Pattern 3: API response cleanup pipe( apiResponse, compact, validateShape(responseSchema), map(normalizeData) ) // Pattern 4: Configuration loading pipe( rawConfig, stripUndefined, validateShape(configSchema), unwrapOr(defaultConfig) ) ``` --- ## Integration with Validation Module The Object module's `validateShape` is designed for **structural validation** (type checking), while the Validation module handles **business logic validation** (error accumulation). ### When to Use Object.validateShape ```typescript // βœ… Type checking unknown API responses const result = validateShape(apiResponse, userSchema) // => Result (fail-fast) // βœ… Runtime type guards const checked = validateShape(data, userSchema) if (isOk(checked)) { // checked.value has the schema's shape } ``` ### When to Use Validation Module ```typescript import { schema, validate, required, email, minLength, min } from '@ontologyio/receta/validation' // βœ… Business rules with error accumulation (fail-slow) const userFormSchema = { name: validate([required('Name is required'), minLength(3, 'Name too short')]), email: validate([required('Email is required'), email('Invalid email')]), age: min(18, 'Must be 18+') } // All errors at once const result = schema(userFormSchema, { name: 'Al', email: 'nope', age: 17 }) // => Invalid([{ field: 'name', errors: ['Name too short'] }, { field: 'email', errors: ['Invalid email'] }, { field: 'age', errors: ['Must be 18+'] }]) ``` ### Using Both Together ```typescript import { flatMap, fromResult } from '@ontologyio/receta/validation' // Step 1: Structural validation (fail-fast) const structuralCheck = validateShape(unknownData, typeSchema) // Step 2: Business validation (fail-slow) const businessCheck = pipe( fromResult(structuralCheck), flatMap(validateBusinessRules) ) ``` --- ## Next Steps - **[Transformation](./transformation.mdx)** - mapKeys, mapValues, filterKeys, filterValues - **[Patterns & Recipes](./patterns.mdx)** - Complete real-world solutions - **[API Reference](../reference/object.mdx)** - Quick lookup and cheat sheets --- # Overview Option represents values that might not exist, providing a type-safe alternative to null/undefined that composes cleanly in functional pipelines. ## The Problem: The Billion-Dollar Mistake ### Null Reference Errors Everywhere ```typescript nocheck // Traditional nullable code function getUserEmail(userId: string): string | undefined { const user = users.find(u => u.id === userId) return user?.email } // Using it requires constant null checks const email = getUserEmail('123') if (email !== undefined) { sendEmail(email.toUpperCase()) // Safe, but verbose } // Easy to forget checks - runtime error! const email2 = getUserEmail('456') sendEmail(email2.toUpperCase()) // πŸ’₯ Cannot read property 'toUpperCase' of undefined ``` **Problems with nullable types:** 1. **Easy to forget checks** - Nothing forces you to handle `null`/`undefined` 2. **Doesn't compose** - Can't chain operations without repeated checks 3. **Silent failures** - `user?.email?.toLowerCase()` returns `undefined` silently 4. **Type information lost** - `string | undefined` doesn't tell you *why* it's undefined ### How Successful Products Handle This **Rust's Option type:** ```rust // Rust forces you to handle None case fn get_user(id: &str) -> Option { users.find(|u| u.id == id) } // Compiler error if you don't handle None! let email = get_user("123").map(|u| u.email); // Returns Option ``` **Scala's Option:** ```scala // Database queries return Option def findById(id: String): Option[User] = users.find(_.id == id) // Chainable transformations val email = findById("123") .map(_.email) .map(_.toUpperCase) .getOrElse("NO EMAIL") ``` **Swift's Optional:** ```swift // Optional is built into the language func getUser(id: String) -> User? { return users.first(where: { $0.id == id }) } // if-let syntax forces handling if let user = getUser("123") { print(user.email) } ``` ## The Solution: `Option` ```typescript import { type Option, some, none, map, unwrapOr } from '@ontologyio/receta/option' import * as R from 'remeda' // Returns Option instead of nullable function getUserEmail(userId: string): Option { const user = users.find(u => u.id === userId) return user ? some(user.email) : none() } // Compose safely without null checks const email = R.pipe( getUserEmail('123'), map(e => e.toUpperCase()), unwrapOr('NO EMAIL') ) // If user doesn't exist, returns 'NO EMAIL' // If user exists, returns 'ALICE@EXAMPLE.COM' // Never forget to handle missing values - type system enforces it! const emailOrDefault: string = unwrapOr(getUserEmail('123'), 'default@example.com') ``` ## Why Option Over Nullable Types? ### Problem 1: Nullable Types Don't Compose ```typescript // ❌ Traditional nullable - verbose and error-prone function getUppercaseEmail(userId: string): string { const user = users.find(u => u.id === userId) if (!user) return 'NO EMAIL' const email = user.email if (!email) return 'NO EMAIL' return email.toUpperCase() } // βœ… Option - clean composition const getUppercaseEmail = (userId: string): string => R.pipe( findUserById(userId), map(u => u.email), map(e => e.toUpperCase()), unwrapOr('NO EMAIL') ) ``` ### Problem 2: Nullable Types Lose Type Information ```typescript // ❌ Why is it undefined? User not found? Email missing? Permission denied? function getUserEmail(userId: string): string | undefined { const user = users.find(u => u.id === userId) if (!user) return undefined // Could be any reason return user.email } // βœ… Option for existence, Result for detailed errors function getUserEmail(userId: string): Option { const user = users.find(u => u.id === userId) return user ? some(user.email) : none() } function getUserEmailOrError(userId: string): Result { const user = users.find(u => u.id === userId) if (!user) return err({ code: 'USER_NOT_FOUND', userId }) if (!user.email) return err({ code: 'EMAIL_MISSING', userId }) return ok(user.email) } ``` ### Problem 3: Nullable Types Don't Work in Pipelines ```typescript // ❌ Can't pipe nullable values cleanly const result = R.pipe( getUserId(), findUser, // returns User | undefined // What now? Can't continue pipe if undefined ) // βœ… Option works seamlessly in pipes const result = R.pipe( getUserId(), findUser, // returns Option map(u => u.email), filter(e => e.includes('@')), unwrapOr('guest@example.com') ) ``` ## Real-World Use Cases ### 1. Database Queries ```typescript // findById pattern const findUserById = (id: string): Option => fromNullable(users.find(u => u.id === id)) const user = findUserById('123') match(user, { onSome: u => console.log(`Found: ${u.name}`), onNone: () => console.log('User not found') }) ``` ### 2. Configuration Loading ```typescript // Optional environment variables const getConfig = (key: string): Option => fromNullable(process.env[key]) const config = { apiKey: unwrapOr(getConfig('API_KEY'), 'default-key'), timeout: pipe( getConfig('TIMEOUT'), map(s => Number(s)), filter(n => n > 0), unwrapOr(5000) ) } ``` ### 3. Form Validation (Optional Fields) ```typescript // Some fields are optional type ProfileForm = { name: string email: string phone?: string // Optional website?: string // Optional } const validatePhone = (phone: string | undefined): Option => pipe( fromNullable(phone), filter(p => /^\d{10}$/.test(p)) ) const profile = { name: form.name, email: form.email, phone: toNullable(validatePhone(form.phone)), website: toNullable(fromNullable(form.website)) } ``` ### 4. API Responses (Optional Data) ```typescript // GitHub API - some fields are optional type GitHubUser = { login: string name: string | null // Optional company: string | null // Optional location: string | null // Optional } const getUserInfo = (user: GitHubUser) => ({ login: user.login, displayName: unwrapOr(fromNullable(user.name), user.login), company: fromNullable(user.company), location: fromNullable(user.location) }) ``` ### 5. Parsing and Conversion ```typescript // Parse values that might fail const parsePositiveInt = (str: string): Option => pipe( tryCatch(() => Number(str)), filter(n => !Number.isNaN(n) && n > 0 && Number.isInteger(n)) ) const age = parsePositiveInt('25') // Some(25) const invalid = parsePositiveInt('abc') // None const negative = parsePositiveInt('-5') // None ``` ## Mental Model: A Box That Might Be Empty Think of Option as a box: - **Some(value)** - Box contains a value - **None** - Box is empty ``` Some(42) None β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 42 β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ (full) (empty) ``` Operations work on the contents: ```typescript // map: Transform contents if present map(some(5), x => x * 2) // Some(10) map(none(), (x: number) => x * 2) // None (no contents to transform) // flatMap: Open nested boxes flatMap(some(5), x => some(x * 2)) // Some(10) flatMap(some(5), x => none()) // None // filter: Keep only if predicate passes filter(some(5), x => x > 0) // Some(5) filter(some(-5), x => x > 0) // None ``` ## When to Use Option ### βœ… Use Option when: **1. Value might not exist for valid reasons** ```typescript title="Signature" // User might not be found - that's not an error findUserById(id): Option // Configuration might not be set - use default getConfig(key): Option // Search might return no results - that's valid searchUsers(query): Option ``` **2. You want to chain operations safely** ```typescript pipe( findUser(id), map(u => u.email), map(e => e.toUpperCase()), unwrapOr('NO EMAIL') ) ``` **3. You're working with optional fields** ```typescript type User = { name: string email: string phone?: string // Optional } const phone = fromNullable(user.phone) ``` ### ❌ Don't use Option for: **1. Errors that need explanation** ```typescript title="Signature" // ❌ Bad - no error details function parseJSON(str: string): Option // βœ… Good - error has details function parseJSON(str: string): Result ``` **2. Multiple possible error types** ```typescript title="Signature" // ❌ Bad - can't distinguish error types function fetchUser(id: string): Option // βœ… Good - explicit error types function fetchUser(id: string): Result ``` **3. When absence is truly exceptional** ```typescript title="Signature" // ❌ Bad - missing config is a setup error function getRequiredConfig(key: string): Option // βœ… Good - throw or return Result function getRequiredConfig(key: string): Result ``` ## Option vs Result vs Nullable | Use Case | Best Choice | Why | |----------|-------------|-----| | Database query by ID | `Option` | Not found is valid, not an error | | API request | `Result` | Need error details (network, 404, 500) | | Optional form field | `Option` or `string \| undefined` | Simple presence/absence | | Parsing with validation | `Result` | Need to know *why* it failed | | Configuration with default | `Option` | Missing β†’ use default | | Required configuration | `Result` | Missing is an error | ## Quick Comparison ```typescript // Nullable type const email: string | undefined = user?.email if (email !== undefined) { sendEmail(email) } // Option type const maybeEmail: Option = fromNullable(user?.email) match(maybeEmail, { onSome: sendEmail, onNone: () => console.log('No email') }) // In a pipeline pipe( fromNullable(user?.email), map(e => e.toLowerCase()), filter(e => e.includes('@')), unwrapOr('no-email@example.com') ) ``` ## What's Next? - **[Constructors](./constructors.mdx)** - Creating Options (`some`, `none`, `fromNullable`) - **[Transformers](./transformers.mdx)** - Working with Options (`map`, `flatMap`, `filter`) - **[Extractors](./extractors.mdx)** - Getting values out (`unwrap`, `match`, `unwrapOr`) - **[Patterns](./patterns.mdx)** - Real-world recipes (DB queries, forms, config) - **[Migration Guide](./migration.mdx)** - From nullable types to Option ## Core Concepts 1. **Option is a discriminated union** - `Some | None` 2. **Type-safe** - Compiler ensures you handle both cases 3. **Composable** - Works in functional pipelines 4. **Explicit** - Makes optionality visible in types 5. **No magic** - Simple data structures with helper functions Ready to start? Check out **[Constructors](./constructors.mdx)** to learn how to create Options. --- # Option Combinators > Working with multiple Options and converting between types. ## Overview | Function | Purpose | Signature | |----------|---------|-----------| | `collect` | All Some β†’ Some(array), any None β†’ None | `Option[] β†’ Option` | | `partition` | Separate Some values and None count | `Option[] β†’ [T[], number]` | | `toResult` | Convert to Result with error | `Option β†’ Result` | | `toNullable` | Convert to nullable | `Option β†’ T \| null` | ## collect() All-or-nothing: returns Some(array) only if all Options are Some. ```typescript title="Signature" collect(options: readonly Option[]): Option ``` ### Usage ```typescript collect([some(1), some(2), some(3)]) // => Some([1, 2, 3]) collect([some(1), none(), some(3)]) // => None ``` ### Real-World: Form Validation ```typescript const validateForm = (data: SignupForm) => { const fields = collect([ validateEmail(data.email), validatePassword(data.password), validateAge(data.age) ]) return map(fields, ([email, password, age]) => ({ email, password, age })) } // Returns Some(validData) only if all fields valid // Returns None if any field invalid ``` ## partition() Separate Some values from Nones. ```typescript title="Signature" partition(options: readonly Option[]): [T[], number] ``` ### Usage ```typescript const [values, noneCount] = partition([ some(1), none(), some(2), none(), some(3) ]) // values: [1, 2, 3] // noneCount: 2 ``` ### Real-World: Batch Operations ```typescript const processUsers = async (ids: string[]) => { const results = await Promise.all(ids.map(fetchUser)) const [users, failedCount] = partition(results) console.log(`Processed: ${users.length}, Failed: ${failedCount}`) return users } ``` ## toResult() Convert Option to Result, providing error for None case. ```typescript title="Signature" toResult(option: Option, error: E): Result ``` ### Usage ```typescript toResult(some(42), 'error') // => Ok(42) toResult(none(), 'No value') // => Err('No value') ``` ### Real-World: API Error Responses ```typescript const getUser = (id: string): Result => R.pipe( findUserById(id), toResult({ code: 'NOT_FOUND', message: `User ${id} not found` }) ) ``` ## toNullable() Convert Option back to nullable type. ```typescript title="Signature" toNullable(option: Option): T | null ``` ### Usage ```typescript toNullable(some(42)) // => 42 toNullable(none()) // => null ``` ### Real-World: External API Payloads ```typescript const apiPayload = { userId: toNullable(maybeUserId), email: toNullable(maybeEmail), metadata: toNullable(maybeMetadata) } // Converts Options to null for JSON serialization ``` ## Next Steps - **[Patterns](./patterns.mdx)** - Real-world combinator recipes - **[API Reference](../reference/option.mdx)** - Complete signatures --- # Option Constructors > Creating Option values from data, nullable types, Results, and fallible operations. ## Overview Constructors are functions that create `Option` values. There are 5 main constructors: | Constructor | Purpose | Use Case | |------------|---------|----------| | `some(value)` | Wrap a known value | When you have a value that exists | | `none()` | Represent absence | When you know there's no value | | `fromNullable(value)` | Convert nullable | Working with `T \| null \| undefined` | | `fromResult(result)` | Convert from Result | When you don't need error details | | `tryCatch(fn)` | Wrap throwing code | When function might throw | ## some() Creates a `Some` Option containing a value. ```typescript title="Signature" function some(value: T): Some ``` ### Basic Usage ```typescript import { some } from '@ontologyio/receta/option' const num = some(42) // => { _tag: 'Some', value: 42 } const str = some('hello') // => { _tag: 'Some', value: 'hello' } const obj = some({ name: 'John', age: 30 }) // => { _tag: 'Some', value: { name: 'John', age: 30 } } ``` ### Real-World: Database Query Success ```typescript // When you successfully find a record const findUserById = (id: string): Option => { const user = users.find(u => u.id === id) return user ? some(user) : none() } // Usage const user = findUserById('123') // If found: Some({ id: '123', name: 'Alice', ... }) // If not found: None ``` ### Real-World: API Response with Data ```typescript // Stripe - Payment intent with amount type PaymentIntent = { id: string amount: number currency: string } const getPaymentAmount = (intent: PaymentIntent): Option => some(intent.amount) // Always returns Some because amount always exists ``` ### When to Use Use `some()` when: - βœ… You have a value that definitely exists - βœ… You're building an Option from known data - βœ… You're returning success from a function that returns Option Don't use `some()` for: - ❌ Values that might be null/undefined (use `fromNullable` instead) - ❌ Operations that might fail (use `tryCatch` instead) ## none() Creates a `None` Option representing absence of a value. ```typescript title="Signature" function none(): None ``` ### Basic Usage ```typescript import { none } from '@ontologyio/receta/option' const missing = none() // => None // Type parameter is usually inferred from context const noUser: Option = none() ``` ### Real-World: Database Query Miss ```typescript // When record isn't found const findUserByEmail = (email: string): Option => { const user = users.find(u => u.email === email) return user ? some(user) : none() } // Usage const user = findUserByEmail('notfound@example.com') // => None ``` ### Real-World: Optional Configuration ```typescript // Feature flag that's not set const getFeatureFlag = (name: string): Option => { const flag = featureFlags[name] return flag !== undefined ? some(flag) : none() } // If flag doesn't exist const isEnabled = getFeatureFlag('new-ui') // => None ``` ### Real-World: Search with No Results ```typescript // GitHub API - searching with no matches const searchRepos = (query: string): Option => { const results = repos.filter(r => r.name.includes(query)) return results.length > 0 ? some(results) : none() } // No repositories match const matches = searchRepos('xyz123notfound') // => None ``` ### When to Use Use `none()` when: - βœ… You know there's no value - βœ… An operation didn't find what it was looking for - βœ… A search returned no results - βœ… An optional field is missing ## fromNullable() Converts nullable values (`T | null | undefined`) to Option. ```typescript title="Signature" function fromNullable(value: T | null | undefined): Option ``` ### Basic Usage ```typescript import { fromNullable } from '@ontologyio/receta/option' fromNullable(42) // => Some(42) fromNullable(null) // => None fromNullable(undefined) // => None // Falsy values that are NOT null/undefined become Some fromNullable(0) // => Some(0) fromNullable('') // => Some('') fromNullable(false) // => Some(false) ``` ### Real-World: Array.find() ```typescript // Array.find returns T | undefined const users = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ] const findUser = (id: string): Option => fromNullable(users.find(u => u.id === id)) findUser('1') // => Some({ id: '1', name: 'Alice' }) findUser('99') // => None ``` ### Real-World: DOM APIs ```typescript // querySelector returns Element | null const getElement = (selector: string): Option => fromNullable(document.querySelector(selector)) const button = getElement('#submit-btn') // If found: Some(Element) // If not found: None ``` ### Real-World: Map/Record Access ```typescript // Map.get() returns V | undefined const cache = new Map() const getCached = (key: string): Option => fromNullable(cache.get(key)) // Record access can be undefined const config: Record = { theme: 'dark' } const getSetting = (key: string): Option => fromNullable(config[key]) ``` ### Real-World: API Responses with Null Fields ```typescript // GitHub API - many fields are nullable type GitHubUser = { login: string name: string | null bio: string | null company: string | null location: string | null } const getUserDisplay = (user: GitHubUser) => ({ username: user.login, displayName: fromNullable(user.name), bio: fromNullable(user.bio), company: fromNullable(user.company), location: fromNullable(user.location) }) // Can now use Option functions on all fields pipe( getUserDisplay(githubUser), display => unwrapOr(display.displayName, display.username) ) ``` ### When to Use Use `fromNullable()` when: - βœ… Working with APIs that return `null` or `undefined` - βœ… Converting TypeScript nullable types to Option - βœ… Array methods like `find()`, `at()`, `shift()`, `pop()` - βœ… Map/Record access - βœ… DOM APIs ## fromResult() Converts a Result to an Option, discarding error information. ```typescript title="Signature" function fromResult(result: Result): Option ``` ### Basic Usage ```typescript import { fromResult } from '@ontologyio/receta/option' import { ok, err } from '@ontologyio/receta/result' fromResult(ok(42)) // => Some(42) fromResult(err('error')) // => None // Error details are discarded fromResult(err({ code: 'NOT_FOUND' })) // => None ``` ### Real-World: Parsing When Error Doesn't Matter ```typescript import { tryCatch as resultTryCatch } from '@ontologyio/receta/result' // You have a Result from parsing const parseJSON = (str: string): Result => resultTryCatch( () => JSON.parse(str), e => e as SyntaxError ) // But you only care if it succeeded, not why it failed const maybeData = (str: string): Option => fromResult(parseJSON(str)) maybeData('{"name":"John"}') // => Some({ name: 'John' }) // SyntaxError is discarded maybeData('invalid') // => None ``` ### Real-World: Validation Chain ```typescript // Start with detailed validation const validateEmail = (email: string): Result => { if (!email.includes('@')) { return err({ field: 'email', message: 'Must contain @' }) } if (email.length < 3) { return err({ field: 'email', message: 'Too short' }) } return ok(email) } // In some contexts, you just need to know if it's valid const hasValidEmail = (email: string): boolean => pipe( validateEmail(email), fromResult, isSome ) hasValidEmail('user@example.com') // => true // Error details not needed hasValidEmail('invalid') // => false ``` ### Real-World: Database Query with Error Logging ```typescript // Query returns Result with error details declare function queryUser(id: string): Promise> // In analytics, you only care about success rate const trackUserLookup = async (id: string) => { const result = await queryUser(id) // Log full error elsewhere if (isErr(result)) { logger.error('User query failed', result.error) } // For metrics, just track success/failure const found = fromResult(result) metrics.increment('user_lookup', { found: isSome(found) }) return found } ``` ### When to Use Use `fromResult()` when: - βœ… You have a Result but only care about success/failure - βœ… Error details are logged elsewhere - βœ… Converting detailed errors to simple presence/absence - βœ… You need Option's composability but have a Result Don't use if: - ❌ You need the error information later - ❌ Different errors should be handled differently ## tryCatch() Wraps potentially throwing code, returning None if it throws. ```typescript title="Signature" function tryCatch(fn: () => T): Option ``` ### Basic Usage ```typescript import { tryCatch } from '@ontologyio/receta/option' // Success case tryCatch(() => JSON.parse('{"name":"John"}')) // => Some({ name: 'John' }) // Error case tryCatch(() => JSON.parse('invalid')) // => None // Works with any throwing code tryCatch(() => { if (Math.random() > 0.5) throw new Error('fail') return 42 }) // Some(42) or None ``` ### Real-World: JSON Parsing ```typescript // Parse JSON, don't care about error details const parseJSON = (str: string): Option => tryCatch(() => JSON.parse(str) as T) const config = parseJSON('{"timeout":5000}') // => Some({ timeout: 5000 }) const invalid = parseJSON('not json') // => None ``` ### Real-World: Number Parsing ```typescript // Parse and validate in one go const parsePositiveInt = (str: string): Option => pipe( tryCatch(() => { const n = Number(str) if (Number.isNaN(n)) throw new Error('Not a number') if (n <= 0) throw new Error('Not positive') if (!Number.isInteger(n)) throw new Error('Not an integer') return n }) ) parsePositiveInt('42') // => Some(42) parsePositiveInt('-5') // => None parsePositiveInt('3.14') // => None parsePositiveInt('abc') // => None ``` ### Real-World: URL Parsing ```typescript // URL constructor throws on invalid URLs const parseURL = (str: string): Option => tryCatch(() => new URL(str)) parseURL('https://example.com') // => Some(new URL('https://example.com')) parseURL('not a url') // => None // Use in pipeline const getHostname = (urlStr: string): string => pipe( parseURL(urlStr), map(url => url.hostname), unwrapOr('unknown') ) getHostname('https://github.com/user/repo') // => 'github.com' getHostname('invalid') // => 'unknown' ``` ### Real-World: Date Parsing ```typescript // Date constructor can create Invalid Date const parseDate = (str: string): Option => tryCatch(() => { const date = new Date(str) if (isNaN(date.getTime())) throw new Error('Invalid date') return date }) parseDate('2024-01-15') // => Some(new Date('2024-01-15')) parseDate('not a date') // => None ``` ### When to Use Use `tryCatch()` when: - βœ… Function might throw but you don't need error details - βœ… Parsing operations (JSON, numbers, URLs, dates) - βœ… DOM operations that might fail - βœ… Converting exceptions to Option Use Result's `tryCatch` if: - ❌ You need to know *why* it failed - ❌ Different errors need different handling ## Comparison Table | Constructor | Input | Output | Use Case | |------------|-------|--------|----------| | `some(42)` | Value | `Some(42)` | You have a value | | `none()` | Nothing | `None` | You know it's missing | | `fromNullable(null)` | `T \| null \| undefined` | `Some(T)` or `None` | Converting nullable types | | `fromResult(ok(42))` | `Result` | `Some(T)` or `None` | Discarding error details | | `tryCatch(() => ...)` | Throwing function | `Some(T)` or `None` | Wrapping exceptions | ## Cheat Sheet ```typescript // Create Some const val = some(42) // Create None const missing = none() // From nullable (most common) const found = fromNullable(users.find(u => u.id === id)) // From Result (discard errors) const succeeded = fromResult(validateEmail(email)) // From throwing code (parsing, etc) const parsed = tryCatch(() => JSON.parse(str)) ``` ## Next Steps - **[Type Guards](../reference/option.mdx#issome)** - Checking if Option is Some or None - **[Transformers](./transformers.mdx)** - Mapping and transforming Options - **[Extractors](./extractors.mdx)** - Getting values out of Options - **[Patterns](./patterns.mdx)** - Real-world constructor recipes --- # Option Extractors > Getting values out of Options safely. ## Overview Extractors are terminal operations that get values out of Options. They handle both Some and None cases. | Function | Returns | Use Case | |----------|---------|----------| | `unwrap` | `T` (throws if None) | When you're certain it's Some | | `unwrapOr` | `T` | Provide a default value | | `unwrapOrElse` | `T` | Compute default lazily | | `match` | `R` | Handle both cases explicitly | | `tap` | `Option` | Side effects, returns original | | `tapNone` | `Option` | Side effects on None | ## unwrap() Extract value or throw error. ```typescript title="Signature" unwrap(option: Option): T ``` ### Usage ```typescript unwrap(some(42)) // => 42 unwrap(none()) // throws Error: Cannot unwrap None ``` ### Real-World: When You Know It Exists ```typescript // After validation, you know it exists const config = unwrap(validateConfig(rawConfig)) // In tests expect(unwrap(findUserById('test-id'))).toEqual(mockUser) ``` ⚠️ **Use sparingly** - Prefer `unwrapOr` or `unwrapOrElse` for production code. ## unwrapOr() Extract value or return default. ```typescript title="Signature" // Data-first unwrapOr(option: Option, defaultValue: T): T // Data-last unwrapOr(defaultValue: T): (option: Option) => T ``` ### Basic Usage ```typescript unwrapOr(some(42), 0) // => 42 unwrapOr(none(), 0) // => 0 ``` ### Real-World: Configuration with Defaults ```typescript const config = { timeout: unwrapOr(getEnv('TIMEOUT'), '5000'), host: unwrapOr(getEnv('HOST'), 'localhost'), port: unwrapOr(getEnv('PORT'), '3000') } ``` ### Real-World: User Display Names ```typescript const getDisplayName = (user: GitHubUser): string => unwrapOr(fromNullable(user.name), user.login) // Fallback to username if name not set ``` ### In Pipelines ```typescript const getUserEmail = (id: string): string => R.pipe( findUserById(id), map(u => u.email), unwrapOr('noreply@example.com') ) ``` ## unwrapOrElse() Extract value or compute default lazily. ```typescript title="Signature" // Data-first unwrapOrElse(option: Option, fn: () => T): T // Data-last unwrapOrElse(fn: () => T): (option: Option) => T ``` ### Basic Usage ```typescript unwrapOrElse(some(42), () => computeDefault()) // 42 (fn not called) unwrapOrElse(none(), () => computeDefault()) // result of computeDefault() ``` ### Real-World: Expensive Defaults ```typescript // Only fetch default user if needed const getUser = (id: string): User => unwrapOrElse( findUserById(id), () => fetchGuestUser() // Only called if user not found ) ``` ### Real-World: Timestamp Generation ```typescript const getTimestamp = (cached: Option): number => unwrapOrElse(cached, () => Date.now()) // Only generate new timestamp if cache empty ``` ## match() Pattern match on Some/None cases. ```typescript title="Signature" // Data-first match( option: Option, patterns: { onSome: (value: T) => R onNone: () => R } ): R // Data-last match(patterns: {...}): (option: Option) => R ``` ### Basic Usage ```typescript match(some(42), { onSome: n => `Value: ${n}`, onNone: () => 'No value' }) // => 'Value: 42' ``` ### Real-World: HTTP Status Codes ```typescript const getStatusCode = (user: Option): number => match(user, { onSome: () => 200, onNone: () => 404 }) ``` ### Real-World: UI Rendering ```tsx const renderUser = (maybeUser: Option) => match(maybeUser, { onSome: user => , onNone: () => }) ``` ### In Pipelines ```typescript const processUser = (id: string): string => R.pipe( findUserById(id), match({ onSome: user => `Found: ${user.name}`, onNone: () => 'User not found' }) ) ``` ## tap() Run side effects without changing the Option. ```typescript title="Signature" // Data-first tap(option: Option, fn: (value: T) => void): Option // Data-last tap(fn: (value: T) => void): (option: Option) => Option ``` ### Basic Usage ```typescript const result = tap(some(42), x => console.log('Value:', x)) // Logs: "Value: 42" // Returns: Some(42) unchanged ``` ### Real-World: Logging ```typescript const user = R.pipe( findUserById(id), tap(u => logger.info('User found', { userId: u.id })), map(u => u.email) ) ``` ### Real-World: Debugging Pipelines ```typescript const result = R.pipe( parseInput(str), tap(x => console.log('Parsed:', x)), map(x => x * 2), tap(x => console.log('Doubled:', x)), filter(x => x > 10), tap(x => console.log('Filtered:', x)) ) ``` ## tapNone() Run side effects when Option is None. ```typescript title="Signature" // Data-first tapNone(option: Option, fn: () => void): Option // Data-last tapNone(fn: () => void): (option: Option) => Option ``` ### Basic Usage ```typescript const result = tapNone(none(), () => console.log('Value missing')) // Logs: "Value missing" // Returns: None unchanged ``` ### Real-World: Error Logging ```typescript const user = R.pipe( findUserById(id), tapNone(() => logger.warn('User not found', { userId: id })), unwrapOr(guestUser) ) ``` ### Real-World: Metrics ```typescript const result = R.pipe( cacheGet(key), tapNone(() => metrics.increment('cache_miss')), unwrapOrElse(() => fetchFromDb(key)) ) ``` ## Comparison Table | Extractor | Return Type | Throws | Use Case | |-----------|-------------|--------|----------| | `unwrap` | `T` | Yes | Tests, after validation | | `unwrapOr` | `T` | No | Simple default value | | `unwrapOrElse` | `T` | No | Expensive/lazy default | | `match` | `R` | No | Different logic per case | | `tap` | `Option` | No | Logging, side effects | | `tapNone` | `Option` | No | Error logging | ## Cheat Sheet ```typescript // Extract or throw unwrap(option) // Extract or use default unwrapOr(option, defaultValue) // Extract or compute default unwrapOrElse(option, () => computeDefault()) // Handle both cases match(option, { onSome: value => doSomething(value), onNone: () => doSomethingElse() }) // Log without changing R.pipe( option, tap(x => console.log(x)), tapNone(() => console.log('missing')) ) ``` ## Anti-Patterns ### ❌ Don't: unwrap() in production ```typescript // Bad - throws in production const email = unwrap(getUserEmail(id)) ``` ### βœ… Do: unwrapOr() with default ```typescript // Good - safe default const email = unwrapOr(getUserEmail(id), 'noreply@example.com') ``` ### ❌ Don't: Nested if statements ```typescript // Bad - verbose let result if (isSome(option)) { result = doSomething(option.value) } else { result = doSomethingElse() } ``` ### βœ… Do: Use match() ```typescript // Good - clean const result = match(option, { onSome: doSomething, onNone: doSomethingElse }) ``` ## Next Steps - **[Combinators](./combinators.mdx)** - Working with arrays of Options - **[Patterns](./patterns.mdx)** - Real-world extractor recipes - **[API Reference](../reference/option.mdx)** - Complete function signatures --- # Migration Guide From Nullable to Option > Step-by-step guide to refactoring nullable types to Option. ## Why Migrate? **Before: Nullable types** ```typescript nocheck function getUserEmail(id: string): string | undefined { const user = users.find(u => u.id === id) return user?.email } // Easy to forget null checks const email = getUserEmail('123') sendEmail(email.toUpperCase()) // πŸ’₯ Runtime error! ``` **After: Option** ```typescript function getUserEmail(id: string): Option { const user = users.find(u => u.id === id) return user ? some(user.email) : none() } // Type system forces you to handle absence const email = unwrapOr(getUserEmail('123'), 'noreply@example.com') sendEmail(email.toUpperCase()) // βœ“ Always safe ``` ## Step 1: Identify Nullable Returns ### Find Functions Returning Nullable ```typescript title="Signature" // ❌ Before function findUser(id: string): User | undefined function getConfig(key: string): string | null function parseNumber(str: string): number | undefined ``` ### Convert to Option ```typescript title="Signature" // βœ… After function findUser(id: string): Option function getConfig(key: string): Option function parseNumber(str: string): Option ``` ## Step 2: Replace Constructors ### From Explicit null/undefined ```typescript // ❌ Before function findUser(id: string): User | undefined { const user = users.find(u => u.id === id) return user ?? undefined } // βœ… After function findUser(id: string): Option { const user = users.find(u => u.id === id) return user ? some(user) : none() } // Or simpler: function findUser(id: string): Option { return fromNullable(users.find(u => u.id === id)) } ``` ### From Array Methods ```typescript // ❌ Before const first = arr[0] ?? undefined const found = arr.find(predicate) ?? undefined // βœ… After const first = fromNullable(arr[0]) const found = fromNullable(arr.find(predicate)) ``` ## Step 3: Replace Null Checks ### From if statements ```typescript // ❌ Before const user = findUser(id) if (user !== undefined) { console.log(user.name) } else { console.log('Not found') } // βœ… After const user = findUser(id) match(user, { onSome: u => console.log(u.name), onNone: () => console.log('Not found') }) ``` ### From Optional Chaining ```typescript // ❌ Before const email = user?.email?.toLowerCase() // βœ… After const email = pipe( fromNullable(user), map(u => u.email), map(e => e.toLowerCase()) ) ``` ### From Nullish Coalescing ```typescript // ❌ Before const timeout = config.timeout ?? 5000 // βœ… After const timeout = unwrapOr(fromNullable(config.timeout), 5000) ``` ## Step 4: Replace Chains ### From Multiple Checks ```typescript // ❌ Before function getTeamLeadEmail(userId: string): string | undefined { const user = users.find(u => u.id === userId) if (!user) return undefined const team = teams.find(t => t.id === user.teamId) if (!team) return undefined const lead = users.find(u => u.id === team.leadId) if (!lead) return undefined return lead.email } // βœ… After function getTeamLeadEmail(userId: string): Option { return pipe( findUserById(userId), flatMap(user => findTeamById(user.teamId)), flatMap(team => findUserById(team.leadId)), map(lead => lead.email) ) } ``` ## Step 5: Handle Extraction ### Simple Defaults ```typescript // ❌ Before const value = maybeValue ?? defaultValue // βœ… After const value = unwrapOr(maybeValue, defaultValue) ``` ### Computed Defaults ```typescript // ❌ Before const value = maybeValue ?? expensiveComputation() // βœ… After const value = unwrapOrElse(maybeValue, () => expensiveComputation()) ``` ## Incremental Migration Strategy ### 1. Start at the Edges Migrate leaf functions first: ```typescript // Start here βœ“ declare function findUser(id: string): Option // Then here function getTeamLead(userId: string): Option { return pipe( findUser(userId), // Already migrated flatMap(user => findTeam(user.teamId)) ) } ``` ### 2. Use Conversion Functions Bridge between old and new code: ```typescript // New code using Option declare function findUserOption(id: string): Option // Legacy wrapper function findUser(id: string): User | undefined { return toNullable(findUserOption(id)) } // Gradually phase out legacy wrapper ``` ### 3. Module by Module ```typescript // database.ts - fully migrated export declare function findById(id: string): Option // api.ts - partially migrated, uses conversion import { findById } from './database' const user = toNullable(findById(id)) // Eventually migrate api.ts too ``` ## Common Pitfalls ### ❌ Forgetting to Handle None ```typescript // Bad - doesn't handle None const email = unwrap(getUserEmail(id)) // Throws! ``` ### βœ… Always Provide Fallback ```typescript // Good const email = unwrapOr(getUserEmail(id), 'noreply@example.com') ``` ### ❌ Mixing Nullable and Option ```typescript title="Signature" // Confusing - mixed types function process(x: Option): string | undefined ``` ### βœ… Pick One Style Per Module ```typescript title="Signature" // Clear - consistent types function process(x: Option): Option ``` ## Migration Checklist - [ ] Identify all functions returning `T | null | undefined` - [ ] Convert return types to `Option` - [ ] Replace `null`/`undefined` returns with `some()`/`none()` - [ ] Replace null checks with `match()` or `unwrapOr()` - [ ] Convert chains to `pipe()` with `map()`/`flatMap()` - [ ] Add tests for both Some and None cases - [ ] Update TypeScript types - [ ] Remove `!` non-null assertions - [ ] Remove optional chaining that's no longer needed ## Next Steps - **[Patterns](./patterns.mdx)** - Common Option patterns - **[API Reference](../reference/option.mdx)** - Complete API --- # Option Patterns & Recipes > Production-ready patterns for common use cases. ## Database Queries ### findById Pattern ```typescript type User = { id: string; name: string; email: string } const findUserById = (id: string): Option => fromNullable(users.find(u => u.id === id)) const getUserEmail = (id: string): string => R.pipe( findUserById(id), map(u => u.email), unwrapOr('noreply@example.com') ) ``` ### Nested Lookups ```typescript // User β†’ Team β†’ Lead const findTeamLead = (userId: string): Option => R.pipe( findUserById(userId), flatMap(user => fromNullable(user.teamId)), flatMap(findTeamById), flatMap(team => fromNullable(team.leadId)), flatMap(findUserById) ) ``` ## Configuration Loading ### Environment Variables with Defaults ```typescript const getEnv = (key: string): Option => fromNullable(process.env[key]) const config = { apiKey: unwrapOr(getEnv('API_KEY'), 'default-key'), timeout: pipe( getEnv('TIMEOUT'), map(Number), filter(n => !isNaN(n) && n > 0), unwrapOr(5000) ), debug: unwrapOr(getEnv('DEBUG'), 'false') === 'true' } ``` ### Feature Flags ```typescript const getFeatureFlag = (name: string): boolean => pipe( fromNullable(featureFlags[name]), unwrapOr(false) ) if (getFeatureFlag('new-ui')) { renderNewUI() } else { renderLegacyUI() } ``` ## Form Validation ### Multi-Field Validation ```typescript const validateEmail = (email: string): Option => pipe( fromNullable(email), filter(e => e.length >= 3), filter(e => e.includes('@')) ) const validateAge = (age: string): Option => pipe( tryCatch(() => Number(age)), filter(n => !isNaN(n)), filter(n => n >= 18 && n <= 120) ) const validateForm = (form: SignupForm): Option => pipe( validateEmail(form.email), flatMap(email => map(validateAge(form.age), age => ({ email, age }))) ) ``` `collect` needs every Option to hold the same type. Here one holds a `string` and the other a `number`, so `flatMap` combines them instead. ### Optional Fields ```typescript type ProfileForm = { name: string // Required email: string // Required phone?: string // Optional website?: string // Optional } const processForm = (form: ProfileForm) => ({ name: form.name, email: form.email, phone: pipe( fromNullable(form.phone), filter(p => /^\d{10}$/.test(p)), toNullable() ), website: pipe( fromNullable(form.website), filter(w => w.startsWith('http')), toNullable() ) }) ``` ## API Integration ### GitHub API ```typescript type GitHubUser = { login: string name: string | null bio: string | null company: string | null } const formatUser = (user: GitHubUser) => ({ username: user.login, displayName: unwrapOr(fromNullable(user.name), user.login), bio: fromNullable(user.bio), company: fromNullable(user.company) }) ``` ### Stripe API ```typescript type PaymentIntent = { id: string amount: number metadata?: Record } const getMetadata = (intent: PaymentIntent, key: string): Option => pipe( fromNullable(intent.metadata), flatMap(meta => fromNullable(meta[key])) ) const orderId = unwrapOr( getMetadata(intent, 'order_id'), 'unknown' ) ``` ## Caching ### With Fallback ```typescript const cache = new Map() const getUser = (id: string): Promise => match(fromNullable(cache.get(id)), { onSome: user => Promise.resolve(user), onNone: () => fetchUserFromDB(id).then(user => { cache.set(id, user) return user }) }) ``` ## Parsing ### JSON with Validation ```typescript const parseConfig = (str: string, validate: (x: unknown) => x is T): Option => flatMap(tryCatch(() => JSON.parse(str)), value => validate(value) ? some(value) : none() ) const config = parseConfig(configStr, isValidConfig) ``` ### Number Parsing ```typescript const parsePositiveInt = (str: string): Option => pipe( tryCatch(() => Number(str)), filter(n => !isNaN(n)), filter(n => Number.isInteger(n)), filter(n => n > 0) ) ``` ## Batch Processing ```typescript const processUserIds = (ids: string[]): User[] => { const results = ids.map(findUserById) const [users, failedCount] = partition(results) logger.info(`Processed ${users.length}, Failed: ${failedCount}`) return users } ``` ## Integration with Result ### Convert for Detailed Errors ```typescript const getUserOrError = (id: string): Result => pipe( findUserById(id), toResult({ code: 'NOT_FOUND', userId: id }) ) ``` ### Discard Error Details ```typescript const hasUser = (id: string): boolean => pipe( validateAndFetchUser(id), // Returns Result fromResult, isSome ) ``` ## Next Steps - **[Migration Guide](./migration.mdx)** - From nullable types - **[API Reference](../reference/option.mdx)** - Complete API --- # Option Transformers > Functions that transform Option values while preserving the Some/None state. ## Overview Transformers let you work with values inside Options without manual unwrapping. They handle the Some/None cases automatically. | Function | Purpose | Signature | |----------|---------|-----------| | `map` | Transform the value | `Option β†’ (T β†’ U) β†’ Option` | | `flatMap` | Chain Option-returning functions | `Option β†’ (T β†’ Option) β†’ Option` | | `filter` | Keep value if predicate passes | `Option β†’ (T β†’ boolean) β†’ Option` | | `flatten` | Unwrap nested Options | `Option> β†’ Option` | All transformers support **data-first** and **data-last** (pipe) usage via Remeda's `purry`. ## map() Transform the value inside an Option. ### Signature ```typescript title="Signature" // Data-first map(option: Option, fn: (value: T) => U): Option // Data-last map(fn: (value: T) => U): (option: Option) => Option ``` ### Basic Usage ```typescript import { map, some, none } from '@ontologyio/receta/option' // Transform Some map(some(5), x => x * 2) // => Some(10) // None stays None map(none(), (x: number) => x * 2) // => None ``` ### In Pipelines ```typescript import * as R from 'remeda' const result = R.pipe( some(5), map(x => x * 2), // Some(10) map(x => x + 1), // Some(11) map(x => String(x)) // Some('11') ) ``` ### Real-World: Database + Transformation ```typescript // Find user and get their email const getUserEmail = (id: string): Option => R.pipe( findUserById(id), map(user => user.email), map(email => email.toLowerCase()) ) getUserEmail('123') // => Some('alice@example.com') getUserEmail('999') // => None ``` ### Real-World: API Response Formatting ```typescript // GitHub API - format user display name type GitHubUser = { login: string name: string | null followers: number } const getDisplayName = (user: GitHubUser): string => R.pipe( fromNullable(user.name), map(name => `${name} (@${user.login})`), unwrapOr(user.login) ) getDisplayName({ login: 'torvalds', name: 'Linus Torvalds', followers: 1000 }) // => 'Linus Torvalds (@torvalds)' getDisplayName({ login: 'octocat', name: null, followers: 500 }) // => 'octocat' ``` ## flatMap() Chain operations that return Options, avoiding nested `Option>`. ### Signature ```typescript title="Signature" // Data-first flatMap(option: Option, fn: (value: T) => Option): Option // Data-last flatMap(fn: (value: T) => Option): (option: Option) => Option ``` ### Basic Usage ```typescript import { flatMap, some, none } from '@ontologyio/receta/option' // Function returns Option const half = (x: number) => x % 2 === 0 ? some(x / 2) : none() flatMap(some(10), half) // => Some(5) flatMap(some(11), half) // => None flatMap(none(), half) // => None ``` ### Real-World: Nested Lookups ```typescript // User has optional team, team has optional lead type User = { id: string; teamId?: string } type Team = { id: string; leadId?: string } const findTeamLead = (userId: string): Option => R.pipe( findUserById(userId), flatMap(user => fromNullable(user.teamId) ), flatMap(teamId => findTeamById(teamId) ), flatMap(team => fromNullable(team.leadId) ), flatMap(leadId => findUserById(leadId) ) ) // Returns Some(lead) if all lookups succeed, None if any fail ``` ### Real-World: Validation Chains ```typescript const validateAge = (age: number): Option => age >= 18 && age <= 120 ? some(age) : none() const parseAndValidateAge = (input: string): Option => R.pipe( tryCatch(() => Number(input)), filter(n => !Number.isNaN(n)), flatMap(validateAge) ) parseAndValidateAge('25') // => Some(25) // Too young parseAndValidateAge('15') // => None // Not a number parseAndValidateAge('abc') // => None ``` ## filter() Keep the value only if a predicate passes. ### Signature ```typescript title="Signature" // Data-first filter(option: Option, predicate: (value: T) => boolean): Option // Data-last filter(predicate: (value: T) => boolean): (option: Option) => Option ``` ### Basic Usage ```typescript import { filter, some, none } from '@ontologyio/receta/option' filter(some(5), x => x > 0) // => Some(5) filter(some(-5), x => x > 0) // => None filter(none(), (x: number) => x > 0) // => None ``` ### Real-World: Form Validation ```typescript // Validate email format const validateEmail = (email: string): Option => R.pipe( fromNullable(email), filter(e => e.length >= 3), filter(e => e.includes('@')), filter(e => e.includes('.')) ) validateEmail('user@example.com') // => Some('user@example.com') validateEmail('invalid') // => None validateEmail('') // => None ``` ### Real-World: Configuration Validation ```typescript // Parse and validate timeout config const getTimeout = (): number => R.pipe( fromNullable(process.env.TIMEOUT), map(s => Number(s)), filter(n => !Number.isNaN(n)), filter(n => n > 0), filter(n => n <= 60000), unwrapOr(5000) // Default 5 seconds ) ``` ## flatten() Remove one level of nesting from `Option>`. ### Signature ```typescript title="Signature" // Data-first flatten(option: Option>): Option // Data-last flatten(): (option: Option>) => Option ``` ### Basic Usage ```typescript import { flatten, some, none } from '@ontologyio/receta/option' flatten(some(some(42))) // => Some(42) flatten(some(none())) // => None flatten(none()) // => None ``` ### Real-World: Avoiding Nesting ```typescript // Without flatten - nested Option const lookup1 = (id: string): Option> => R.pipe( findTeamById(id), map(team => findUserById(team.leadId)) ) // Returns Option> - awkward! // With flatten const lookup2 = (id: string): Option => R.pipe( findTeamById(id), map(team => findUserById(team.leadId)), flatten() ) // Returns Option - clean! // Or use flatMap instead const lookup3 = (id: string): Option => R.pipe( findTeamById(id), flatMap(team => findUserById(team.leadId)) ) ``` ## Combining Transformers ### Pipeline Example ```typescript const processUserId = (idStr: string): Option => R.pipe( // Parse ID tryCatch(() => Number(idStr)), // Validate it's positive filter(id => id > 0), // Convert to string map(id => String(id)), // Look up user flatMap(id => findUserById(id)), // Filter active users only filter(user => user.active) ) processUserId('123') // Some(user) if found and active processUserId('-1') // None (negative ID) processUserId('abc') // None (parse failed) ``` ## Cheat Sheet ```typescript // Transform value map(option, x => x * 2) // Chain Option-returning functions flatMap(option, x => lookup(x)) // Keep if predicate passes filter(option, x => x > 0) // Flatten nested Options flatten(nestedOption) // In pipeline R.pipe( some(5), map(x => x * 2), filter(x => x > 5), flatMap(x => lookup(x)) ) ``` ## When to Use Which | Scenario | Use | |----------|-----| | Transform the value | `map` | | Value doesn't change type | `map` | | Next operation returns Option | `flatMap` | | Conditional keeping | `filter` | | Have `Option>` | `flatten` or use `flatMap` instead | ## Next Steps - **[Extractors](./extractors.mdx)** - Getting values out of Options - **[Combinators](./combinators.mdx)** - Working with arrays of Options - **[Patterns](./patterns.mdx)** - Real-world transformation recipes --- # Overview Predicates are reusable, type-safe functions for filtering, validation, and type narrowing. Build complex conditions from simple building blocks like gt(18), eq('active'), and where({ age: gt(18), status: eq('active') }). ## The Problem: Scattered Filtering Logic ### The Hidden Cost of Inline Filters ```typescript // ❌ Traditional approach - logic scattered everywhere const activeAdults = users.filter(u => u.age >= 18 && u.active === true) const activeStaff = users.filter(u => u.age >= 18 && u.active === true && (u.role === 'admin' || u.role === 'moderator')) const premiumUsers = users.filter(u => u.age >= 18 && u.active === true && u.premium) // What's duplicated? The "adult active user" logic (age >= 18 && active === true) // What if requirements change? Update 3+ places // What if logic is complex? Copy-paste grows ``` **Problems with inline filters:** 1. **Duplication**: Same logic repeated across codebase 2. **Not reusable**: Can't extract or compose conditions 3. **Hard to test**: Logic embedded in larger functions 4. **Not discoverable**: No central place to find "what filters exist?" 5. **Type unsafe**: No type narrowing for mixed-type arrays ### How Production Applications Handle This #### Prisma ORM (Database Queries) ```typescript // Prisma's where clause - composable predicates for databases const users = await prisma.user.findMany({ where: { age: { gte: 18 }, status: { in: ['active', 'premium'] }, email: { contains: '@gmail.com' } } }) ``` #### MongoDB Queries ```typescript // MongoDB - query operators are predicates db.users.find({ age: { $gt: 18 }, status: { $in: ['active', 'premium'] }, 'profile.verified': true }) ``` #### TypeORM Repository ```typescript // TypeORM - composable conditions const users = await userRepository.find({ where: { age: MoreThan(18), status: In(['active', 'premium']), verified: true } }) ``` **What they all share:** - βœ… Composable conditions (combine small pieces) - βœ… Named operators (`gte`, `in`, `contains`) - βœ… Type-safe filtering - βœ… Reusable patterns ## The Solution: Predicate Functions ```typescript import * as R from 'remeda' import { where, gte, eq, oneOf } from '@ontologyio/receta/predicate' // βœ… Define reusable predicates const isAdult = gte(18) const isActive = eq(true) const isStaff = oneOf(['admin', 'moderator']) // βœ… Compose into higher-level predicates const isActiveAdult = where({ age: isAdult, active: isActive }) const isActiveStaff = where({ age: isAdult, active: isActive, role: isStaff }) // βœ… Use anywhere R.filter(users, isActiveAdult) R.filter(users, isActiveStaff) R.find(users, isActiveStaff) ``` **Benefits:** - πŸ”„ **Reusable**: Define once, use everywhere - 🧩 **Composable**: Build complex from simple - πŸ”’ **Type-safe**: TypeScript knows what you're filtering - πŸ§ͺ **Testable**: Test predicates in isolation - πŸ“– **Discoverable**: All filters in one module - 🎯 **Explicit**: Clear intent (`gte(18)` vs `>= 18`) ## Why Predicates Over Inline Filters? ### Problem 1: Inline Filters Don't Compose ```typescript // ❌ Can't extract or reuse users.filter(u => u.age >= 18 && u.verified) products.filter(p => p.price >= 100 && p.inStock) orders.filter(o => o.amount >= 1000 && o.status === 'pending') // Each has similar structure (threshold check + boolean) // But you can't extract a "greaterThan" helper easily ``` ```typescript // βœ… With predicates - composition is natural import { gte, eq, where } from '@ontologyio/receta/predicate' const isAdult = gte(18) const isExpensive = gte(100) const isLargeOrder = gte(1000) R.filter(users, where({ age: isAdult, verified: Boolean })) R.filter(products, where({ price: isExpensive, inStock: Boolean })) R.filter(orders, where({ amount: isLargeOrder, status: eq('pending') })) ``` ### Problem 2: Inline Filters Lose Type Information ```typescript // ❌ TypeScript can't narrow this inline check const mixed: unknown[] = ['hello', 42, NaN, 'world'] const numbers = mixed.filter(x => typeof x === 'number' && !isNaN(x)) // Type of numbers: unknown[] ❌ (should be number[]) ``` ```typescript // βœ… Type guards narrow types automatically import { isNumber } from '@ontologyio/receta/predicate' const numbers = R.filter(mixed, isNumber) // Type of numbers: number[] βœ… ``` ### Problem 3: Inline Filters Don't Work in Pipelines ```typescript // ❌ Awkward in pipe - need arrow functions R.pipe( users, R.filter(u => u.age >= 18), R.filter(u => u.active), R.filter(u => u.role === 'admin') ) ``` ```typescript // βœ… Clean pipeline with predicates R.pipe( users, R.filter(prop('age', gte(18))), R.filter(prop('active', Boolean)), R.filter(prop('role', eq('admin'))) ) // Or combine into one: R.pipe( users, R.filter(where({ age: gte(18), active: Boolean, role: eq('admin') })) ) ``` ## Real-World Use Cases ### 1. API Filtering (GitHub-style) ```typescript interface Issue { id: number state: 'open' | 'closed' labels: string[] assignee: string | null } // Filter: Open bugs assigned to someone const openAssignedBugs = where({ state: eq('open'), labels: (labels: string[]) => labels.includes('bug'), assignee: not(isNull) }) R.filter(issues, openAssignedBugs) ``` ### 2. E-Commerce Product Search ```typescript // Price range + rating + availability const affordableQuality = where({ price: between(50, 200), rating: gte(4.0), inStock: Boolean }) R.filter(products, affordableQuality) ``` ### 3. User Permissions ```typescript // Complex permission logic const canAccessAdmin = or( prop('role', eq('admin')), and( prop('role', eq('moderator')), prop('verified', Boolean), by((u: User) => u.joinedDays, gt(30)) ) ) if (canAccessAdmin(currentUser)) { // Grant access } ``` ### 4. Data Validation ```typescript // Validate form data const isValidEmail = matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) const isStrongPassword = and( by((s: string) => s.length, gte(8)), matches(/[A-Z]/), matches(/[0-9]/) ) const errors: string[] = [] if (!isValidEmail(formData.email)) errors.push('Invalid email') if (!isStrongPassword(formData.password)) errors.push('Weak password') ``` ### 5. Database-Like Queries ```typescript // SQL: SELECT * FROM orders WHERE status IN ('pending', 'shipped') AND amount > 100 const activeHighValueOrders = where({ status: oneOf(['pending', 'shipped']), amount: gt(100) }) R.filter(orders, activeHighValueOrders) ``` ## Mental Model: SQL WHERE Clauses for Arrays Think of predicates as **SQL WHERE clauses** for in-memory data: ```sql -- SQL SELECT * FROM users WHERE age >= 18 AND status = 'active' ``` ```typescript // Receta Predicate (same structure) R.filter(users, where({ age: gte(18), status: eq('active') })) ``` | SQL Operator | Predicate Function | |--------------|-------------------| | `>`, `>=`, `<`, `<=` | `gt()`, `gte()`, `lt()`, `lte()` | | `=`, `!=` | `eq()`, `neq()` | | `BETWEEN` | `between(min, max)` | | `IN (...)` | `oneOf([...])` | | `LIKE '%text%'` | `includes('text')` | | `IS NULL` | `isNull`, `isNullish` | | `IS NOT NULL` | `isDefined` | | `AND` | `and(...)` | | `OR` | `or(...)` | | `NOT` | `not(...)` | ## When to Use Predicates ### βœ… Use Predicates When: - **Filtering collections** - Arrays, sets, maps - **Complex conditions** - More than 2 checks combined - **Reusable logic** - Same filter used multiple times - **Type narrowing** - Working with `unknown` or union types - **Validation** - Checking if data meets criteria - **Permissions** - User access control logic - **Search/Query** - Building search interfaces - **Testing** - Need to verify conditions in isolation ### ❌ Don't Use For: - **One-off simple checks** - `if (x > 5)` is fine for unique cases - **Non-boolean logic** - Predicates return true/false only - **Mutations** - Predicates should be pure (no side effects) - **Complex transformations** - Use `map` or `flatMap` instead ## Predicate Categories ### 1. Comparison (`gt`, `lt`, `eq`, `between`) For numeric, string, and value comparisons. ```typescript gt(18) // age > 18 between(10, 50) // 10 <= price <= 50 eq('active') // status === 'active' ``` ### 2. Combinators (`and`, `or`, `not`) For logical composition. ```typescript and(gt(18), lt(65)) // 18 < age < 65 or(eq('admin'), eq('owner')) // role === 'admin' OR 'owner' not(isEmpty) // not empty ``` ### 3. Builders (`where`, `oneOf`, `prop`) For structured object filtering. ```typescript where({ age: gt(18), active: Boolean }) // Multi-field check oneOf(['red', 'blue', 'green']) // Value in list R.filter(users, prop('status', eq('active'))) // Single property check ``` ### 4. Type Guards (`isString`, `isNumber`, `isDefined`) For type narrowing and runtime checks. ```typescript isString // typeof x === 'string' isNumber // typeof x === 'number' && !isNaN(x) isDefined // x != null ``` ## Architecture: Pure Functions ```typescript // A predicate is just: (value: T) => boolean type Predicate = (value: T) => boolean // Simple predicates const isEven: Predicate = (n) => n % 2 === 0 const isPositive: Predicate = (n) => n > 0 // Predicate factories (return predicates) const gt = (threshold: T): Predicate => (value) => value > threshold const eq = (expected: T): Predicate => (value) => value === expected // Higher-order combinators (predicates β†’ predicate) const and = (...preds: Predicate[]): Predicate => (value) => preds.every(p => p(value)) ``` **Key properties:** - βœ… **Pure**: No side effects - βœ… **Composable**: Combine freely - βœ… **Type-safe**: TypeScript checks everything - βœ… **Lazy**: Only evaluated when called - βœ… **Cacheable**: Same input = same output ## Integration with Remeda Predicates work seamlessly with Remeda's collection functions: ```typescript import * as R from 'remeda' import { where, gt, gte, lt, eq, prop } from '@ontologyio/receta/predicate' const users: User[] = [/* ... */] // filter - Keep matching items R.filter(users, where({ age: gt(18) })) // find - First match R.find(users, where({ role: eq('admin') })) // partition - Split by predicate const [active, inactive] = R.partition(users, prop('active', Boolean)) // takeWhile - Take until predicate fails R.takeWhile(numbers, lt(10)) // dropWhile - Drop until predicate fails R.dropWhile(numbers, lt(10)) // Remeda has no some or every, so use the array methods users.some(prop('verified', Boolean)) users.every(where({ age: gte(18) })) ``` ## Performance Considerations ### Short-Circuit Evaluation ```typescript // βœ… and() short-circuits on first false and( expensiveCheck, // If this fails... anotherCheck // ...this never runs ) // βœ… or() short-circuits on first true or( quickCheck, // If this succeeds... expensiveCheck // ...this never runs ) ``` ### Predicate Factories Are Fast ```typescript // Creating predicates is cheap (no computation) const isAdult = gt(18) // Just returns a function const isActive = eq(true) // Just returns a function // Only when you CALL them does work happen isAdult(25) // Now we compute: 25 > 18 ``` ### Memoization for Expensive Predicates ```typescript import { memoize } from '@ontologyio/receta/memo' // If predicate does expensive work, memoize it const expensivePredicate = memoize((user: User) => { // Complex calculation return user.score > computeThreshold(user.history) }) ``` ## Common Patterns ### Pattern 1: Define Domain Predicates ```typescript // predicates/user.ts export const isAdult = where({ age: gte(18) }) export const isVerified = where({ verified: Boolean }) export const isStaff = where({ role: oneOf(['admin', 'moderator']) }) export const isActiveUser = where({ status: eq('active'), verified: Boolean }) ``` ### Pattern 2: Compose for Permissions ```typescript // permissions.ts const canEditPost = (user: User, post: Post) => or( where({ id: eq(post.authorId) }), // Author can edit and(isStaff, isVerified) // Verified staff can edit )(user) ``` ### Pattern 3: Validation Schemas ```typescript const isValidUser = where({ email: matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), age: between(13, 120), username: by((s: string) => s.length, between(3, 20)) }) if (!isValidUser(userData)) { throw new Error('Invalid user data') } ``` ## What's Next? Now that you understand the "why" of predicates, dive into specific APIs: 1. **[Comparison Predicates](./comparison.mdx)** - `gt`, `lt`, `eq`, `between`, string matching 2. **[Combinators](./combinators.mdx)** - `and`, `or`, `not`, `xor` for logic 3. **[Builders](./builders.mdx)** - `where`, `oneOf`, `prop` for structured queries 4. **[Type Guards](./guards.mdx)** - `isString`, `isNumber`, type narrowing 5. **[Common Patterns](./patterns.mdx)** - Real-world recipes and examples 6. **[Migration Guide](./migration.mdx)** - From inline filters to predicates 7. **[API Reference](../reference/predicate.mdx)** - Complete function listing Or jump straight to **[Common Patterns](./patterns.mdx)** for copy-paste recipes! --- # Predicate Builders > Build complex object predicates using database-like query patterns. ## Overview | Builder | Purpose | Example | |---------|---------|---------| | `where(schema)` | Multi-field object check | `where({ age: gt(18), active: Boolean })` | | `oneOf(values)` | Value in list | `oneOf(['admin', 'owner'])` | | `prop(key, pred)` | Single property check | `prop('status', eq('active'))` | | `matchesShape(pattern)` | Property values match | `matchesShape({ type: 'click' })` | | `hasProperty(key)` | Property exists | `hasProperty('email')` | | `satisfies(pred)` | Custom condition | `satisfies((x) => x > 0)` | | `by(selector, pred)` | Test derived value | `by((u) => u.tags.length, gt(2))` | **Mental Model:** Think of these as **SQL WHERE clauses** for in-memory objects. --- ## where() - Multi-Field Object Filtering ### Signature ```typescript title="Signature" const where = >( schema: PredicateSchema ): Predicate ``` The `where` builder creates predicates that test multiple object properties. All predicates in the schema must pass. ### Basic Usage ```typescript import * as R from 'remeda' import { where, gt, eq } from '@ontologyio/receta/predicate' interface User { age: number name: string active: boolean } const users: User[] = [ { age: 25, name: 'Alice', active: true }, { age: 17, name: 'Bob', active: true }, { age: 30, name: 'Charlie', active: false } ] // Active adults const activeAdult = where({ age: gt(18), active: Boolean // shorthand for (v) => Boolean(v) }) R.filter(users, activeAdult) // => [{ age: 25, name: 'Alice', active: true }] ``` ### Database-Like Queries Think of `where()` like SQL or MongoDB queries: ```typescript // SQL: SELECT * FROM products WHERE price >= 100 AND inStock = true const query = where({ price: gte(100), inStock: eq(true) }) R.filter(products, query) ``` ```typescript // MongoDB: db.orders.find({ status: 'pending', amount: { $gt: 1000 } }) const query = where({ status: eq('pending'), amount: gt(1000) }) R.filter(orders, query) ``` ```typescript // Prisma: prisma.user.findMany({ where: { role: 'admin', verified: true } }) const query = where({ role: eq('admin'), verified: eq(true) }) R.filter(users, query) ``` ### Real-World Examples #### E-Commerce Product Search ```typescript interface Product { id: number name: string price: number category: string rating: number inStock: boolean tags: string[] } // Products: electronics, $50-200, rated 4+, in stock const affordableQualityElectronics = where({ category: eq('electronics'), price: between(50, 200), rating: gte(4.0), inStock: eq(true) }) R.filter(products, affordableQualityElectronics) // With custom predicate for tags const gamingAccessories = where({ category: eq('electronics'), tags: (tags: string[]) => tags.includes('gaming'), inStock: eq(true) }) ``` #### User Permissions ```typescript interface User { id: string role: 'user' | 'admin' | 'moderator' verified: boolean accountAgeDays: number premiumTier: number } // Verified admins const verifiedAdmin = where({ role: eq('admin'), verified: eq(true) }) // Senior moderators (90+ days, verified) const seniorModerator = where({ role: eq('moderator'), verified: eq(true), accountAgeDays: gte(90) }) // Premium users const premiumUser = where({ verified: eq(true), premiumTier: gt(0) }) ``` #### GitHub-Style Issue Filtering ```typescript interface Issue { id: number state: 'open' | 'closed' labels: string[] assignee: string | null comments: number } // Open bugs assigned to someone const openAssignedBugs = where({ state: eq('open'), labels: (labels: string[]) => labels.includes('bug'), assignee: not(isNull) }) // High-priority issues needing attention const criticalIssues = where({ state: eq('open'), labels: (labels: string[]) => labels.includes('critical') || labels.includes('security'), comments: lt(2) // Few comments = needs attention }) ``` #### Order Management ```typescript interface Order { id: string status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' amount: number customerId: string createdAt: Date isPriority: boolean } // Active high-value orders const activeHighValue = where({ status: oneOf(['pending', 'processing']), amount: gte(1000), isPriority: eq(true) }) // Recent large orders const recentLargeOrders = where({ amount: gt(5000), createdAt: (date: Date) => date.getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000 // Last 7 days }) ``` ### Nested Objects ```typescript interface Order { id: string amount: number customer: { name: string country: string verified: boolean } } // For nested properties, use custom predicates const usVerifiedOrders = where({ amount: gt(100), customer: (customer: Order['customer']) => customer.country === 'US' && customer.verified }) // Or nest another where() const usVerifiedOrders2 = where({ amount: gt(100), customer: where({ country: eq('US'), verified: eq(true) }) }) ``` --- ## oneOf() - Value in List ### Signature ```typescript title="Signature" const oneOf = (values: readonly T[]): Predicate ``` Check if a value is in a list. Uses strict equality (`===`). ### Basic Usage ```typescript import { oneOf } from '@ontologyio/receta/predicate' const numbers = [1, 2, 3, 4, 5] R.filter(numbers, oneOf([1, 3, 5])) // => [1, 3, 5] const validRoles = oneOf(['admin', 'moderator', 'owner']) R.filter(users, (u) => validRoles(u.role)) ``` ### Real-World Examples #### Multi-Status Filtering ```typescript // Active order statuses const activeStatuses = oneOf(['pending', 'processing', 'shipped']) R.filter(orders, (o) => activeStatuses(o.status)) // With where() const activeOrders = where({ status: oneOf(['pending', 'processing', 'shipped']) }) ``` #### Allowed File Extensions ```typescript const allowedExtensions = oneOf(['.jpg', '.png', '.gif', '.webp']) const imageFiles = R.filter( files, (f) => allowedExtensions(f.extension) ) ``` #### Country Whitelist ```typescript interface User { country: string email: string } const supportedCountries = oneOf(['US', 'CA', 'UK', 'AU', 'DE', 'FR']) const eligibleUsers = R.filter(users, (u) => supportedCountries(u.country)) ``` #### Role-Based Access ```typescript const staffRoles = oneOf(['admin', 'moderator', 'manager']) const canAccessDashboard = (user: User) => staffRoles(user.role) if (canAccessDashboard(currentUser)) { // Show admin dashboard } ``` --- ## prop() - Single Property Check ### Signature ```typescript title="Signature" const prop = ( key: K, predicate: Predicate ): Predicate ``` Test a specific property of an object. ### Basic Usage ```typescript import { prop, gt } from '@ontologyio/receta/predicate' const users = [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 17 }, { name: 'Charlie', age: 30 } ] R.filter(users, prop('age', gt(18))) // => Alice and Charlie ``` ### Real-World Examples #### Pipeline Filtering ```typescript // Clean pipeline with prop() R.pipe( users, R.filter(prop('verified', eq(true))), R.filter(prop('age', gte(18))), R.filter(prop('country', oneOf(['US', 'CA']))) ) // Vs inline (less readable) R.pipe( users, R.filter((u) => u.verified === true), R.filter((u) => u.age >= 18), R.filter((u) => ['US', 'CA'].includes(u.country)) ) ``` #### Nested Property Access ```typescript interface Order { id: string user: { name: string country: string } } // Filter by nested property const usOrders = R.filter( orders, by((o: Order) => o.user.country, eq('US')) ) ``` --- ## matchesShape() - Match Property Values ### Signature ```typescript title="Signature" const matchesShape = >( pattern: Partial ): Predicate ``` Check if an object has specific property values (strict equality). Properties not in the pattern are ignored. ### Basic Usage ```typescript import { matchesShape } from '@ontologyio/receta/predicate' const events = [ { type: 'click', x: 100, y: 200 }, { type: 'keypress', key: 'Enter' }, { type: 'click', x: 150, y: 250 } ] R.filter(events, matchesShape({ type: 'click' })) // => click events ``` ### Real-World Examples #### API Response Filtering ```typescript type ApiResponse = | { status: 'success'; data: unknown } | { status: 'error'; message: string } const responses: ApiResponse[] = [ { status: 'success', data: { id: 1 } }, { status: 'error', message: 'Not found' }, { status: 'success', data: { id: 2 } } ] const successResponses = R.filter( responses, matchesShape({ status: 'success' }) ) ``` #### Event Pattern Matching ```typescript type AppEvent = | { type: 'user.created'; userId: string } | { type: 'user.updated'; userId: string } | { type: 'order.placed'; orderId: string } const userEvents = R.filter( events, or( matchesShape({ type: 'user.created' }), matchesShape({ type: 'user.updated' }) ) ) ``` --- ## hasProperty() - Property Existence ### Signature ```typescript title="Signature" const hasProperty = , K extends string>( key: K ): Predicate ``` Check if an object has a specific property (regardless of value). ### Basic Usage ```typescript import { hasProperty } from '@ontologyio/receta/predicate' const objects = [ { name: 'Alice', age: 25 }, { name: 'Bob' }, { name: 'Charlie', age: 30 } ] R.filter(objects, hasProperty('age')) // => [{ name: 'Alice', age: 25 }, { name: 'Charlie', age: 30 }] ``` ### Real-World Examples #### Optional Fields ```typescript type User = { id: string email?: string phone?: string } // Users with email const hasEmail = R.filter(users, hasProperty('email')) // Users with either email or phone const hasContact = R.filter( users, or(hasProperty('email'), hasProperty('phone')) ) ``` --- ## by() - Test Derived Values ### Signature ```typescript title="Signature" const by = ( selector: (value: T) => U, predicate: Predicate ): Predicate ``` Extract a value and test it with a predicate. Perfect for derived/computed values. ### Basic Usage ```typescript import { by, gt } from '@ontologyio/receta/predicate' const users = [ { name: 'Alice', tags: ['admin', 'user'] }, { name: 'Bob', tags: ['user'] }, { name: 'Charlie', tags: ['admin', 'moderator', 'user'] } ] // Users with multiple tags R.filter(users, by((u) => u.tags.length, gt(1))) // => Alice and Charlie ``` ### Real-World Examples #### Computed Values ```typescript interface Product { name: string price: number discount: number // 0.0 to 1.0 } // Final price > $80 after discount const expensiveAfterDiscount = by( (p: Product) => p.price * (1 - p.discount), gt(80) ) R.filter(products, expensiveAfterDiscount) ``` #### String Length ```typescript // Long usernames (> 10 characters) const longUsername = by( (u: User) => u.username.length, gt(10) ) // Short descriptions const shortDescription = by( (p: Product) => p.description.length, lt(50) ) ``` #### Array Length ```typescript // Users with many followers const popular = by( (u: User) => u.followers.length, gte(1000) ) // Products with reviews const hasReviews = by( (p: Product) => p.reviews.length, gt(0) ) ``` #### Date Calculations ```typescript interface User { createdAt: Date lastLoginAt: Date } // Account age in days const accountAgeDays = (u: User) => Math.floor((Date.now() - u.createdAt.getTime()) / (24 * 60 * 60 * 1000)) // Veteran users (account > 365 days) const veteranUsers = by(accountAgeDays, gt(365)) // Inactive users (no login in 90 days) const daysSinceLogin = (u: User) => Math.floor((Date.now() - u.lastLoginAt.getTime()) / (24 * 60 * 60 * 1000)) const inactiveUsers = by(daysSinceLogin, gt(90)) ``` --- ## satisfies() - Readable Custom Predicates ### Signature ```typescript title="Signature" const satisfies = (predicate: Predicate): Predicate ``` Identity function for readability. Makes complex inline predicates clearer. ### Basic Usage ```typescript import { satisfies } from '@ontologyio/receta/predicate' // Without satisfies (unclear intent) R.filter(numbers, (n) => n % 2 === 0 && n > 10) // With satisfies (clear intent) R.filter(numbers, satisfies((n) => n % 2 === 0 && n > 10)) ``` ### Real-World Examples ```typescript // Complex validation const validUser = satisfies((u: User) => u.email.includes('@') && u.age >= 18 && u.termsAccepted ) // Custom business logic const eligibleForDiscount = satisfies((order: Order) => { const isFirstOrder = order.customer.orderCount === 0 const isHighValue = order.amount > 100 return isFirstOrder && isHighValue }) ``` --- ## Combining Builders ### Complex Queries ```typescript // E-commerce: Premium electronics on sale, highly rated const dealOfTheDay = where({ category: eq('electronics'), price: and(gt(100), lt(500)), rating: gte(4.5), inStock: eq(true), tags: by( (tags: string[]) => tags, (tags) => tags.includes('sale') && tags.includes('featured') ) }) // User access: Verified staff or senior community members const canModerate = or( where({ role: oneOf(['admin', 'moderator']), verified: eq(true) }), where({ role: eq('user'), verified: eq(true), reputation: gt(1000), accountAgeDays: gt(180) }) ) ``` --- ## Pattern: Reusable Domain Predicates Create a predicates module for your domain: ```typescript // predicates/user.ts import { where, and, or, eq, gt, oneOf, by } from '@ontologyio/receta/predicate' export const isAdult = where({ age: gt(18) }) export const isVerified = where({ verified: eq(true) }) export const isStaff = where({ role: oneOf(['admin', 'moderator']) }) export const isActive = where({ status: eq('active'), verified: eq(true) }) export const isPremium = where({ tier: gt(0) }) export const isVeteran = by((u: User) => u.accountAgeDays, gt(365)) // Compose them export const canAccessPremiumContent = or(isPremium, isStaff) export const canModerate = and(isStaff, isVerified) ``` Usage: ```typescript import * as UserPreds from './predicates/user' const activeStaff = R.filter(users, UserPreds.isActive) const moderators = R.filter(users, UserPreds.canModerate) ``` --- ## Next Steps - **[Type Guards](./guards.mdx)** - Type-safe predicates with narrowing - **[Common Patterns](./patterns.mdx)** - Complete real-world recipes - **[API Reference](../reference/predicate.mdx)** - Full API documentation --- # Predicate Combinators > Combine simple predicates into complex conditions using logical operators. ## Overview | Combinator | Purpose | Example | |------------|---------|---------| | `and(...preds)` | All must be true | `and(gt(18), lt(65))` β†’ 18 < age < 65 | | `or(...preds)` | Any must be true | `or(eq('admin'), eq('owner'))` | | `not(pred)` | Invert predicate | `not(isEmpty)` β†’ isNotEmpty | | `xor(...preds)` | Exactly one true | `xor(isPremium, isTrial)` | | `always()` | Always true | Default/fallback case | | `never()` | Always false | Filter out all | **Key Properties:** - βœ… **Short-circuit**: `and` stops on first false, `or` stops on first true - βœ… **Type-safe**: Predicates of one type combine into a predicate of that type. `and` and `or` keep what up to three type guards prove, so the result narrows. `not` returns a plain `Predicate`. - βœ… **Pure**: No side effects, same input = same output - βœ… **Nestable**: Combinators can be nested infinitely --- ## and() - Logical AND ### Signature ```typescript title="Signature" const and = (...predicates: Predicate[]): Predicate ``` Returns true only if **all** predicates return true. Short-circuits on first false. ### Basic Usage ```typescript import * as R from 'remeda' import { and, gte, lt } from '@ontologyio/receta/predicate' // Age range: 18 ≀ age < 65 const workingAge = and(gte(18), lt(65)) const users = [ { name: 'Teen', age: 16 }, { name: 'Adult', age: 30 }, { name: 'Senior', age: 70 } ] R.filter(users, (u) => workingAge(u.age)) // => [{ name: 'Adult', age: 30 }] ``` ### Real-World Examples #### E-Commerce Product Filtering ```typescript import { and, where, gt, lt, eq } from '@ontologyio/receta/predicate' interface Product { id: number name: string price: number category: string inStock: boolean rating: number } // Available quality products under $200 const affordableQuality = where({ price: and(gt(50), lt(200)), rating: gt(4.0), inStock: eq(true) }) R.filter(products, affordableQuality) // Electronics on sale that are in stock const saleElectronics = and( where({ category: eq('electronics') }), where({ inStock: eq(true) }), where({ price: lt(100) }) ) ``` #### User Permissions ```typescript interface User { role: 'admin' | 'moderator' | 'user' verified: boolean accountAgeDays: number premiumTier: number } // Must be verified AND (admin OR experienced moderator) const canAccessModPanel = and( where({ verified: eq(true) }), or( where({ role: eq('admin') }), and( where({ role: eq('moderator') }), where({ accountAgeDays: gt(90) }) ) ) ) if (canAccessModPanel(currentUser)) { // Grant access } ``` #### API Query Filtering ```typescript // GitHub Issues: Open bugs assigned to someone const openAssignedBugs = where({ state: eq('open'), assignee: not(isNull), labels: (labels: string[]) => labels.includes('bug') }) // With additional filters const criticalBugs = and( openAssignedBugs, where({ labels: (labels: string[]) => labels.includes('critical') }) ) ``` ### Short-Circuit Behavior ```typescript import { and } from '@ontologyio/receta/predicate' let expensiveCheckRan = false const cheapCheck = (n: number) => n > 0 const expensiveCheck = (n: number) => { expensiveCheckRan = true // Expensive computation here return n % 2 === 0 } const combined = and(cheapCheck, expensiveCheck) combined(-5) // => false console.log(expensiveCheckRan) // => false (never ran!) combined(3) // => false console.log(expensiveCheckRan) // => true (cheapCheck passed) ``` **Performance tip**: Put fast/cheap checks first! ```typescript // βœ… Good: fast check first and( eq('active'), // Fast equality check complexValidation // Expensive computation ) // ❌ Bad: expensive check first and( complexValidation, eq('active') ) ``` --- ## or() - Logical OR ### Signature ```typescript title="Signature" const or = (...predicates: Predicate[]): Predicate ``` Returns true if **any** predicate returns true. Short-circuits on first true. ### Basic Usage ```typescript import { or, eq } from '@ontologyio/receta/predicate' // Match admin or owner const isStaff = or( eq('admin'), eq('owner'), eq('moderator') ) const users = [ { role: 'admin' }, { role: 'user' }, { role: 'owner' } ] R.filter(users, (u) => isStaff(u.role)) // => admin and owner ``` ### Real-World Examples #### Order Status Filtering ```typescript interface Order { id: number status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' amount: number } // Active orders (not delivered or cancelled) const isActive = or( eq('pending'), eq('processing'), eq('shipped') ) R.filter(orders, (o) => isActive(o.status)) // Or using oneOf (cleaner for multiple values) import { oneOf } from '@ontologyio/receta/predicate' const isActive2 = oneOf(['pending', 'processing', 'shipped']) ``` #### Budget or Premium Tiers ```typescript // Either budget (< $50) or premium (> $500) const budgetOrPremium = or( lt(50), gt(500) ) const products = [ { name: 'Basic', price: 30 }, { name: 'Standard', price: 100 }, { name: 'Premium', price: 600 } ] R.filter(products, (p) => budgetOrPremium(p.price)) // => Basic ($30) and Premium ($600) ``` #### Multi-Role Access Control ```typescript // Can edit if: owner OR verified admin OR senior moderator const canEdit = (user: User, resource: Resource) => or( where({ id: eq(resource.ownerId) }), where({ role: eq('admin'), verified: eq(true) }), where({ role: eq('moderator'), reputation: gt(1000) }) )(user) ``` --- ## not() - Logical NOT ### Signature ```typescript title="Signature" const not = (predicate: Predicate): Predicate ``` Inverts a predicate's result. ### Basic Usage ```typescript import { not, isEmpty } from '@ontologyio/receta/predicate' const names = ['Alice', '', 'Bob', '', 'Charlie'] // Filter non-empty strings R.filter(names, not(isEmpty)) // => ['Alice', 'Bob', 'Charlie'] // Or use the built-in isNotEmpty import { isNotEmpty } from '@ontologyio/receta/predicate' R.filter(names, isNotEmpty) ``` ### Real-World Examples #### Exclude Specific Values ```typescript // All orders except cancelled const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'cancelled' }, { id: 3, status: 'shipped' } ] R.filter(orders, (o) => not(eq('cancelled'))(o.status)) // Or use neq R.filter(orders, (o) => neq('cancelled')(o.status)) ``` #### Invert Complex Conditions ```typescript // Users who are NOT (verified AND premium) const notPremiumVerified = not( and( where({ verified: eq(true) }), where({ tier: eq('premium') }) ) ) // Same as: unverified OR non-premium const equivalent = or( where({ verified: eq(false) }), where({ tier: neq('premium') }) ) ``` #### Filter Out Test Data ```typescript interface User { email: string name: string isTestAccount: boolean } const realUsers = R.filter( users, not(where({ isTestAccount: eq(true) })) ) ``` --- ## xor() - Exclusive OR ### Signature ```typescript title="Signature" const xor = (...predicates: Predicate[]): Predicate ``` Returns true if **exactly one** predicate returns true. ### Basic Usage ```typescript import { xor, lt, gt } from '@ontologyio/receta/predicate' // Numbers outside range 8-12 (< 8 OR > 12, but not both) const outsideRange = xor(lt(8), gt(12)) const numbers = [1, 5, 10, 15, 20] R.filter(numbers, outsideRange) // => [1, 5, 15, 20] // 10 is excluded (neither < 8 nor > 12) ``` ### Real-World Examples #### Subscription Tiers ```typescript interface User { id: number premium: boolean trial: boolean } // Valid if premium XOR trial (one but not both) const validSubscription = xor( where({ premium: eq(true) }), where({ trial: eq(true) }) ) const users = [ { id: 1, premium: true, trial: false }, // βœ… valid { id: 2, premium: false, trial: true }, // βœ… valid { id: 3, premium: true, trial: true }, // ❌ invalid (both) { id: 4, premium: false, trial: false } // ❌ invalid (neither) ] R.filter(users, validSubscription) // => users 1 and 2 ``` #### Feature Flags ```typescript // Product should have EITHER physical shipping OR digital download const validProduct = xor( where({ requiresShipping: eq(true) }), where({ isDigital: eq(true) }) ) ``` --- ## always() / never() - Constant Predicates ### Signatures ```typescript title="Signature" const always = (): Predicate // Always returns true const never = (): Predicate // Always returns false ``` ### Use Cases #### Conditional Filtering ```typescript import { always } from '@ontologyio/receta/predicate' const shouldFilter = config.env === 'production' const predicate = shouldFilter ? where({ verified: eq(true) }) : always() R.filter(users, predicate) // In dev: returns all users // In prod: returns only verified ``` #### Default Cases ```typescript const getFilterPredicate = (role: string): Predicate => { switch (role) { case 'admin': return where({ isAdmin: eq(true) }) case 'moderator': return where({ isModerator: eq(true) }) default: return always() // No filtering } } ``` #### Clear All / Keep All ```typescript import { never } from '@ontologyio/receta/predicate' // Debug mode: don't filter logs const shouldShowLog = debugMode ? always() : (log) => log.level === 'error' // Maintenance mode: block all requests const canAccess = maintenanceMode ? never() : checkPermissions ``` --- ## De Morgan's Laws Combinators follow logical laws that allow refactoring: ```typescript // not(and(A, B)) === or(not(A), not(B)) not(and(isAdult, isVerified)) // Equivalent to: or(not(isAdult), not(isVerified)) // not(or(A, B)) === and(not(A), not(B)) not(or(isAdmin, isOwner)) // Equivalent to: and(not(isAdmin), not(isOwner)) ``` **Example:** ```typescript // "Not (premium AND verified)" means "free OR unverified" const notPremiumVerified = not(and(isPremium, isVerified)) const equivalent = or(not(isPremium), not(isVerified)) // Both work the same! ``` --- ## Performance Tips ### 1. Order Matters in `and` ```typescript // βœ… Fast checks first and( eq('active'), // O(1) equality isValidEmail, // Regex check existsInDatabase // Network call ) // ❌ Slow checks first and( existsInDatabase, isValidEmail, eq('active') ) ``` ### 2. `or` with Likely Cases First ```typescript // If 90% of users are regular users: or( eq('user'), // 90% chance - stops here eq('admin'), // 8% chance eq('superadmin') // 2% chance ) ``` ### 3. Flatten Nested Same Operators ```typescript // ❌ Unnecessary nesting and( and(checkA, checkB), and(checkC, checkD) ) // βœ… Flattened and(checkA, checkB, checkC, checkD) ``` --- ## Common Patterns ### Pattern 1: Range Validation ```typescript // Value within range const inRange = (min: number, max: number) => and(gte(min), lte(max)) // Or use between() import { between } from '@ontologyio/receta/predicate' const inRange2 = between(18, 65) // Cleaner! ``` ### Pattern 2: Multi-Status Check ```typescript // Instead of multiple ORs: or(eq('pending'), eq('processing'), eq('shipped')) // Use oneOf: oneOf(['pending', 'processing', 'shipped']) ``` ### Pattern 3: Permission Check ```typescript const hasPermission = (user: User, permission: string) => or( where({ role: eq('admin') }), // Admins can do anything (u) => u.permissions.includes(permission) // Or has specific permission )(user) ``` ### Pattern 4: Nested Object Filtering ```typescript const validOrder = where({ status: oneOf(['pending', 'processing']), amount: and(gt(0), lt(10000)), customer: where({ verified: eq(true), country: oneOf(['US', 'CA', 'UK']) }) }) ``` --- ## Testing Combinators ```typescript import { describe, it, expect } from 'vitest' import { and, or, not, eq, gt, lt } from '@ontologyio/receta/predicate' describe('Predicate combinators', () => { it('and - all must pass', () => { const inRange = and(gt(10), lt(20)) expect(inRange(15)).toBe(true) expect(inRange(5)).toBe(false) expect(inRange(25)).toBe(false) }) it('or - any can pass', () => { const extremes = or(lt(10), gt(90)) expect(extremes(5)).toBe(true) expect(extremes(95)).toBe(true) expect(extremes(50)).toBe(false) }) it('not - inverts result', () => { const notTen = not(eq(10)) expect(notTen(10)).toBe(false) expect(notTen(5)).toBe(true) }) }) ``` --- ## Next Steps - **[Builders](./builders.mdx)** - Build structured predicates with `where()`, `oneOf()`, `prop()` - **[Type Guards](./guards.mdx)** - Use type-narrowing predicates - **[Common Patterns](./patterns.mdx)** - Real-world recipes --- # Comparison Predicates Comparison predicates test values against thresholds, ranges, and patterns. They're the building blocks for most filtering operations. ## Overview | Function | Purpose | Example | |----------|---------|---------| | `gt(n)` | Greater than | `gt(18)` β†’ age > 18 | | `gte(n)` | Greater or equal | `gte(18)` β†’ age >= 18 | | `lt(n)` | Less than | `lt(100)` β†’ price < 100 | | `lte(n)` | Less or equal | `lte(100)` β†’ price <= 100 | | `eq(v)` | Equals | `eq('active')` β†’ status === 'active' | | `neq(v)` | Not equals | `neq(null)` β†’ value !== null | | `between(min, max)` | Range (inclusive) | `between(18, 65)` β†’ 18 <= age <= 65 | | `startsWith(s)` | String prefix | `startsWith('app')` β†’ 'app.ts' | | `endsWith(s)` | String suffix | `endsWith('.ts')` β†’ 'app.ts' | | `includes(s)` | Substring | `includes('@gmail')` β†’ emails | | `matches(regex)` | Pattern match | `matches(/^\d+$/)` β†’ digits only | | `isEmpty` | Empty check | `isEmpty('')` β†’ true | | `isNotEmpty` | Non-empty check | `isNotEmpty('hi')` β†’ true | --- ## Numeric Comparisons ### `gt(threshold)` - Greater Than ```typescript import * as R from 'remeda' import { gt } from '@ontologyio/receta/predicate' // Filter adults const users = [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 17 }, { name: 'Charlie', age: 30 } ] R.filter(users, (u) => gt(18)(u.age)) // => [{ name: 'Alice', age: 25 }, { name: 'Charlie', age: 30 }] // Filter expensive products const products = [ { name: 'Phone', price: 500 }, { name: 'Cable', price: 15 } ] R.filter(products, (p) => gt(100)(p.price)) // => [{ name: 'Phone', price: 500 }] ``` **Real-world: Stripe transactions over $100** ```typescript const transactions = await fetchTransactions() const largePayments = R.filter(transactions, (t) => gt(10000)(t.amount)) // cents ``` ### `gte(threshold)` - Greater Than or Equal ```typescript import { gte } from '@ontologyio/receta/predicate' // Filter passing scores (>= 60) const scores = [45, 60, 75, 55, 80] R.filter(scores, gte(60)) // => [60, 75, 80] // Minimum order value const orders = [ { id: 1, amount: 50 }, { id: 2, amount: 100 }, { id: 3, amount: 75 } ] R.filter(orders, (o) => gte(75)(o.amount)) // => [{ id: 2, amount: 100 }, { id: 3, amount: 75 }] ``` **Real-world: AWS EC2 instances with enough CPU** ```typescript const instances = await ec2.describeInstances() const powerfulInstances = R.filter( instances, (i) => gte(4)(i.cpuCount) ) ``` ### `lt(threshold)` - Less Than ```typescript import { lt } from '@ontologyio/receta/predicate' // Filter budget items (< $50) const items = [ { name: 'Notebook', price: 5 }, { name: 'Laptop', price: 1000 }, { name: 'Pen', price: 2 } ] R.filter(items, (i) => lt(50)(i.price)) // => [{ name: 'Notebook', price: 5 }, { name: 'Pen', price: 2 }] // Low stock alerts const inventory = [ { sku: 'A', quantity: 5 }, { sku: 'B', quantity: 50 }, { sku: 'C', quantity: 2 } ] const lowStock = R.filter(inventory, (item) => lt(10)(item.quantity)) ``` **Real-world: GitHub issues with few comments** ```typescript const issues = await octokit.issues.listForRepo() const needsAttention = R.filter(issues, (i) => lt(3)(i.comments)) ``` ### `lte(threshold)` - Less Than or Equal ```typescript import { lte } from '@ontologyio/receta/predicate' // Filter items within budget (≀ $100) const shoppingCart = [ { name: 'Shirt', price: 50 }, { name: 'Shoes', price: 100 }, { name: 'Watch', price: 200 } ] R.filter(shoppingCart, (item) => lte(100)(item.price)) // => [{ name: 'Shirt', price: 50 }, { name: 'Shoes', price: 100 }] ``` ### `between(min, max)` - Range Check (Inclusive) ```typescript import { between } from '@ontologyio/receta/predicate' // Filter by age range const users = [ { name: 'Teen', age: 15 }, { name: 'Young', age: 25 }, { name: 'Middle', age: 45 }, { name: 'Senior', age: 70 } ] const workingAge = R.filter(users, (u) => between(18, 65)(u.age)) // => [{ name: 'Young', age: 25 }, { name: 'Middle', age: 45 }] // Price range filtering const products = [ { name: 'Budget Phone', price: 200 }, { name: 'Mid-range Phone', price: 500 }, { name: 'Flagship Phone', price: 1200 } ] const affordable = R.filter(products, (p) => between(300, 800)(p.price)) // => [{ name: 'Mid-range Phone', price: 500 }] ``` **Real-world: E-commerce price slider** ```typescript // User selects price range [50, 200] const priceFilter = between(50, 200) const filtered = R.filter(products, (p) => priceFilter(p.price)) ``` --- ## Equality Comparisons ### `eq(value)` - Equals (Strict ===) ```typescript import { eq } from '@ontologyio/receta/predicate' // Filter by status const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'shipped' }, { id: 3, status: 'pending' } ] R.filter(orders, (o) => eq('pending')(o.status)) // => [{ id: 1, status: 'pending' }, { id: 3, status: 'pending' }] // Find exact matches const numbers = [1, 2, 3, 2, 1] R.filter(numbers, eq(2)) // => [2, 2] ``` **Real-world: Filter Prisma records by status** ```typescript const activeUsers = R.filter(users, (u) => eq('ACTIVE')(u.status)) ``` ### `neq(value)` - Not Equals (Strict !==) ```typescript import { neq } from '@ontologyio/receta/predicate' // Exclude specific value const numbers = [1, 2, 3, 2, 1] R.filter(numbers, neq(2)) // => [1, 3, 1] // Exclude cancelled orders const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'cancelled' }, { id: 3, status: 'shipped' } ] const activeOrders = R.filter(orders, (o) => neq('cancelled')(o.status)) ``` --- ## String Predicates ### `startsWith(prefix)` - String Prefix ```typescript import { startsWith } from '@ontologyio/receta/predicate' // Filter files by prefix const files = ['app.ts', 'app.test.ts', 'utils.ts', 'config.json'] R.filter(files, startsWith('app')) // => ['app.ts', 'app.test.ts'] // Filter by name const users = ['Alice', 'Bob', 'Alex', 'Barbara'] R.filter(users, startsWith('A')) // => ['Alice', 'Alex'] ``` **Real-world: Filter S3 keys by folder** ```typescript const objects = await s3.listObjects({ Bucket: 'my-bucket' }) const images = R.filter(objects, (obj) => startsWith('images/')(obj.Key)) ``` ### `endsWith(suffix)` - String Suffix ```typescript import { endsWith } from '@ontologyio/receta/predicate' // Filter TypeScript files const files = ['app.ts', 'app.js', 'config.json', 'test.spec.ts'] R.filter(files, endsWith('.ts')) // => ['app.ts', 'test.spec.ts'] // Filter by domain const emails = ['alice@gmail.com', 'bob@yahoo.com', 'charlie@gmail.com'] R.filter(emails, endsWith('@gmail.com')) // => ['alice@gmail.com', 'charlie@gmail.com'] ``` **Real-world: Filter image files** ```typescript const allowedExtensions = ['.jpg', '.png', '.gif'] const isImage = (filename: string) => allowedExtensions.some(ext => endsWith(ext)(filename)) ``` ### `includes(substring)` - Substring Match ```typescript import { includes } from '@ontologyio/receta/predicate' // Search functionality const products = [ { name: 'iPhone 15 Pro' }, { name: 'Samsung Galaxy S24' }, { name: 'iPhone 15' } ] const searchTerm = 'iPhone' R.filter(products, (p) => includes(searchTerm)(p.name)) // => [{ name: 'iPhone 15 Pro' }, { name: 'iPhone 15' }] // Filter logs const logs = [ 'INFO: Server started', 'ERROR: Connection failed', 'INFO: Request received' ] R.filter(logs, includes('ERROR')) // => ['ERROR: Connection failed'] ``` **Real-world: Full-text search in user names** ```typescript const searchUsers = (query: string) => R.filter(users, (u) => includes(query.toLowerCase())(u.name.toLowerCase())) ``` ### `matches(regex)` - Regular Expression ```typescript import { matches } from '@ontologyio/receta/predicate' // Validate email format const emails = ['alice@example.com', 'invalid-email', 'bob@test.org'] const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ R.filter(emails, matches(emailPattern)) // => ['alice@example.com', 'bob@test.org'] // Filter phone numbers const phones = ['+1-555-0100', '555-0100', 'not-a-phone'] R.filter(phones, matches(/^\+?\d{1,3}-\d{3}-\d{4}$/)) // => ['+1-555-0100'] // Extract digits-only strings const mixed = ['abc', '123', 'a1b2', '456'] R.filter(mixed, matches(/^\d+$/)) // => ['123', '456'] ``` **Real-world: Validate API tokens** ```typescript const isValidToken = matches(/^[A-Za-z0-9_-]{32}$/) if (!isValidToken(token)) { throw new Error('Invalid token format') } ``` --- ## Empty Checks ### `isEmpty` - Check if Empty Works with strings, arrays, and objects. ```typescript import { isEmpty } from '@ontologyio/receta/predicate' // Strings R.filter(['', 'hello', '', 'world'], isEmpty) // => ['', ''] // Arrays R.filter([[], [1, 2], []], isEmpty) // => [[], []] // Objects R.filter([{}, { a: 1 }, {}], isEmpty) // => [{}, {}] ``` **Real-world: Remove empty form fields** ```typescript const formData = { name: 'Alice', email: '', bio: 'Developer', phone: '' } const filled = R.pipe( R.entries(formData), R.filter(([_, value]) => !isEmpty(value)), R.fromEntries ) // => { name: 'Alice', bio: 'Developer' } ``` ### `isNotEmpty` - Check if Not Empty ```typescript import { isNotEmpty } from '@ontologyio/receta/predicate' // Filter non-empty strings const names = ['', 'Alice', '', 'Bob', 'Charlie'] R.filter(names, isNotEmpty) // => ['Alice', 'Bob', 'Charlie'] // Filter non-empty arrays const batches = [[], [1, 2], [], [3]] R.filter(batches, isNotEmpty) // => [[1, 2], [3]] ``` --- ## Combining Comparisons ```typescript import { and, or, between, gt, gte, lt } from '@ontologyio/receta/predicate' // Adults but not seniors (18 ≀ age < 65) const workingAge = and(gte(18), lt(65)) // Budget or premium (< $50 OR > $500) const budgetOrPremium = or(lt(50), gt(500)) // Using between is cleaner for ranges const teenRange = between(13, 19) ``` --- ## When to Use Each | Predicate | Use When | |-----------|----------| | `gt`, `gte`, `lt`, `lte` | Single-sided thresholds | | `between` | Range checks (inclusive) | | `eq`, `neq` | Exact value matching | | `startsWith` | Prefixes (paths, names) | | `endsWith` | Suffixes (extensions, domains) | | `includes` | Substring search | | `matches` | Complex patterns (regex) | | `isEmpty` / `isNotEmpty` | Presence checks | --- ## Performance Tips ### 1. Use Simplest Predicate ```typescript // βœ… Simpler and faster eq('active') // ❌ Overkill matches(/^active$/) ``` ### 2. Order Matters in AND ```typescript // βœ… Fast check first and( eq('active'), // Quick check expensiveValidation // Only if first passes ) // ❌ Slow check first and( expensiveValidation, eq('active') ) ``` ### 3. Prefer `between` Over Multiple Checks ```typescript // βœ… Single range check between(18, 65) // ❌ Two separate checks and(gte(18), lte(65)) ``` --- ## Common Patterns ### Pattern: Age Groups ```typescript const isChild = lt(13) const isTeen = between(13, 19) const isAdult = between(20, 64) const isSenior = gte(65) ``` ### Pattern: Price Ranges ```typescript const isBudget = lt(50) const isMidRange = between(50, 200) const isPremium = gt(200) ``` ### Pattern: String Validation ```typescript const isEmail = matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) const isPhone = matches(/^\+?\d{10,}$/) const isURL = matches(/^https?:\/\/.+/) ``` --- ## Next Steps - **[Combinators](./combinators.mdx)** - Combine predicates with `and`, `or`, `not` - **[Builders](./builders.mdx)** - Use `where()` for multi-field checks - **[Common Patterns](./patterns.mdx)** - Real-world filtering recipes --- # Type Guards > Runtime type checking with TypeScript type narrowing. ## Overview Type guards are special predicates that narrow TypeScript types at runtime. When used in `if` statements or `filter()`, TypeScript automatically narrows the type. | Guard | Narrows To | Example | |-------|-----------|---------| | `isString` | `string` | `isString(value)` β†’ `value: string` | | `isNumber` | `number` | `isNumber(value)` β†’ excludes NaN | | `isFiniteNumber` | `number` | Excludes NaN, Infinity | | `isInteger` | `number` | Integer numbers only | | `isBoolean` | `boolean` | `true` or `false` | | `isNull` | `null` | Exactly `null` | | `isUndefined` | `undefined` | Exactly `undefined` | | `isNullish` | `null \| undefined` | Nullish values | | `isDefined` | `NonNullable` | Not null or undefined | | `isArray` | `unknown[]` | Any array | | `isObject` | `Record` | Non-null, non-array objects | | `isFunction` | `Function` | Functions | | `isDate` | `Date` | Date instances | | `isRegExp` | `RegExp` | RegExp instances | | `isError` | `Error` | Error instances | | `isPromise` | `Promise` | Promise instances | | `isInstanceOf(Class)` | `Class` | Instance of specific class | --- ## Primitive Type Guards ### isString ```typescript import * as R from 'remeda' import { isString } from '@ontologyio/receta/predicate' // Type narrowing in conditionals const value: unknown = 'hello' if (isString(value)) { console.log(value.toUpperCase()) // TypeScript knows value is string } // Filter mixed arrays const mixed: unknown[] = ['hello', 42, 'world', true, 'test'] const strings = R.filter(mixed, isString) // Type of strings: string[] ``` **Real-world: Parse API responses** ```typescript interface ApiResponse { data: unknown } const parseUserName = (response: ApiResponse): string | null => { if (isString(response.data)) { return response.data } return null } ``` ### isNumber Excludes `NaN` (unlike `typeof value === 'number'`). ```typescript import { isNumber } from '@ontologyio/receta/predicate' const mixed: unknown[] = [42, 'hello', 100, NaN, null] const numbers = R.filter(mixed, isNumber) // => [42, 100] (NaN excluded!) // Type narrowing const value: unknown = 42 if (isNumber(value)) { console.log(value * 2) // TypeScript knows value is number } ``` **Real-world: Validate numeric input** ```typescript const parseQuantity = (input: unknown): number | null => { if (isNumber(input) && input > 0) { return Math.floor(input) } return null } ``` ### isFiniteNumber Excludes `NaN`, `Infinity`, and `-Infinity`. ```typescript import { isFiniteNumber } from '@ontologyio/receta/predicate' isFiniteNumber(42) // => true isFiniteNumber(Infinity) // => false isFiniteNumber(-Infinity) // => false isFiniteNumber(NaN) // => false ``` **Real-world: Validate calculations** ```typescript const safeDivide = (a: number, b: number): number | null => { const result = a / b return isFiniteNumber(result) ? result : null } safeDivide(10, 2) // => 5 safeDivide(10, 0) // => null (Infinity) ``` ### isInteger ```typescript import { isInteger } from '@ontologyio/receta/predicate' const numbers = [1, 1.5, 2, 2.5, 3] R.filter(numbers, isInteger) // => [1, 2, 3] ``` **Real-world: Validate IDs** ```typescript const validateId = (value: unknown): value is number => { return isInteger(value) && value > 0 } ``` ### isBoolean ```typescript import { isBoolean } from '@ontologyio/receta/predicate' const mixed: unknown[] = [true, 1, false, 'yes', 0] const booleans = R.filter(mixed, isBoolean) // => [true, false] // Type: boolean[] ``` --- ## Nullish Type Guards ### isNull ```typescript import { isNull } from '@ontologyio/receta/predicate' const values: unknown[] = [null, undefined, 0, '', false] R.filter(values, isNull) // => [null] ``` ### isUndefined ```typescript import { isUndefined } from '@ontologyio/receta/predicate' const values: unknown[] = [null, undefined, 0, ''] R.filter(values, isUndefined) // => [undefined] ``` ### isNullish Checks for `null` OR `undefined` (uses `== null`). ```typescript import { isNullish, not } from '@ontologyio/receta/predicate' const values: Array = ['hello', null, 'world', undefined] // Remove nullish values const defined = R.filter(values, not(isNullish)) // => ['hello', 'world'] // Type: (string | null | undefined)[], because not() drops the narrowing. Use isDefined to keep it. ``` **Real-world: Clean API responses** ```typescript interface ApiUser { name: string email: string | null phone: string | null address: string | null } const cleanUser = (user: ApiUser) => { const contacts = [user.email, user.phone, user.address] const validContacts = R.filter(contacts, not(isNullish)) return { ...user, contacts: validContacts } } ``` ### isDefined The inverse of `isNullish`. Narrows out `null` and `undefined`. ```typescript import { isDefined } from '@ontologyio/receta/predicate' const values: Array = ['hello', null, 'world', undefined] const defined = R.filter(values, isDefined) // => ['hello', 'world'] // Type: string[] // Type narrowing const value: string | null = getValueFromAPI() if (isDefined(value)) { console.log(value.toUpperCase()) // TypeScript knows value is string } ``` **Real-world: Filter optional array elements** ```typescript interface User { name: string email?: string } const users: User[] = [ { name: 'Alice', email: 'alice@example.com' }, { name: 'Bob' }, { name: 'Charlie', email: 'charlie@example.com' } ] const emails = R.pipe( users, R.map((u) => u.email), R.filter(isDefined) ) // => ['alice@example.com', 'charlie@example.com'] // Type: string[] ``` --- ## Structural Type Guards ### isArray ```typescript import { isArray } from '@ontologyio/receta/predicate' const value: unknown = [1, 2, 3] if (isArray(value)) { console.log(value.length) // TypeScript knows value is array console.log(value[0]) // Access elements } // Check if config value is array const config: Record = { hosts: ['a', 'b'] } if (isArray(config.hosts)) { config.hosts.forEach((host) => connect(host)) } ``` ### isObject Checks for non-null objects that aren't arrays. Functions fail, but class instances such as `Date` and `Map` pass. ```typescript import { isObject } from '@ontologyio/receta/predicate' isObject({}) // => true isObject({ a: 1 }) // => true isObject([]) // => false (array) isObject(null) // => false isObject(() => {}) // => false (function) isObject(new Date()) // => true (any non-array object) ``` **Real-world: Validate JSON** ```typescript const parseConfig = (json: string): Record | null => { try { const parsed = JSON.parse(json) return isObject(parsed) ? parsed : null } catch { return null } } ``` ### isFunction ```typescript import { isFunction } from '@ontologyio/receta/predicate' const mixed: unknown[] = [() => {}, 42, function() {}, 'test', class Foo {}] const functions = R.filter(mixed, isFunction) // Type: Function[] // Check callback const runCallback = (cb: unknown) => { if (isFunction(cb)) { cb() } } ``` --- ## Instance Type Guards ### isDate ```typescript import { isDate } from '@ontologyio/receta/predicate' isDate(new Date()) // => true isDate('2024-01-01') // => false (string) isDate(Date.now()) // => false (number) // Validate date input const parseDate = (value: unknown): Date | null => { return isDate(value) ? value : null } ``` ### isError ```typescript import { isError } from '@ontologyio/receta/predicate' isError(new Error('fail')) // => true isError(new TypeError('invalid')) // => true isError('error message') // => false // Error handling const handleFailure = (error: unknown) => { if (isError(error)) { console.error(error.message) console.error(error.stack) } else { console.error('Unknown error:', error) } } ``` ### isPromise ```typescript import { isPromise } from '@ontologyio/receta/predicate' isPromise(Promise.resolve(42)) // => true isPromise(async () => {}) // => false (function, not promise) const fn = async () => 42 isPromise(fn()) // => true (calling it returns promise) // Auto-await if promise const getValue = async (value: unknown) => { if (isPromise(value)) { return await value } return value } ``` ### isRegExp ```typescript import { isRegExp } from '@ontologyio/receta/predicate' isRegExp(/test/) // => true isRegExp(new RegExp('test')) // => true isRegExp('/test/') // => false (string) ``` ### isInstanceOf Create custom type guards for classes. ```typescript import { isInstanceOf } from '@ontologyio/receta/predicate' class User { constructor(public name: string) {} } class Product { constructor(public name: string) {} } const values: unknown[] = [ new User('Alice'), { name: 'Bob' }, new User('Charlie'), new Product('Widget') ] const users = R.filter(values, isInstanceOf(User)) // Type: User[] // => [User('Alice'), User('Charlie')] ``` **Real-world: Error type checking** ```typescript class ApiError extends Error { constructor(public code: string, message: string) { super(message) } } const handleError = (error: unknown) => { if (isInstanceOf(ApiError)(error)) { console.error(`API Error [${error.code}]: ${error.message}`) } else if (isError(error)) { console.error(`Error: ${error.message}`) } else { console.error('Unknown error:', error) } } ``` --- ## Mixed Array Filtering One of the most powerful uses of type guards: ```typescript import { isString, isNumber } from '@ontologyio/receta/predicate' // API returns mixed types const apiResponse: unknown[] = [ 'user-123', 42, 'admin-456', { id: 789 }, 100 ] // Extract strings const userIds = R.filter(apiResponse, isString) // => ['user-123', 'admin-456'] // Type: string[] // Extract numbers const counts = R.filter(apiResponse, isNumber) // => [42, 100] // Type: number[] ``` **Real-world: Parse webhook payloads** ```typescript interface WebhookPayload { events: unknown[] } const parseEvents = (payload: WebhookPayload) => { const validEvents = R.filter(payload.events, isObject) // Now TypeScript knows validEvents is Record[] return validEvents } ``` --- ## Integration with Result and Option ### With Result ```typescript import { type Result, ok, err } from '@ontologyio/receta/result' import { isObject, isString, isNumber } from '@ontologyio/receta/predicate' const parseUser = (data: unknown): Result => { if (!isObject(data)) { return err('Expected object') } if (!isString(data.name)) { return err('Name must be string') } if (!isNumber(data.age)) { return err('Age must be number') } return ok({ name: data.name, age: data.age }) } ``` ### With Option ```typescript import { type Option, some, none } from '@ontologyio/receta/option' import { isNumber } from '@ontologyio/receta/predicate' const parsePort = (value: unknown): Option => { return isNumber(value) && value > 0 && value < 65536 ? some(value) : none() } ``` --- ## Combining Guards with Predicates `and` returns a plain `Predicate`, so it checks a value without narrowing it. To keep the narrowing, combine guards with `&&` in a guard of your own: ```typescript import { isObject, isString, isNumber } from '@ontologyio/receta/predicate' // Filter for valid users (objects with string name and number age > 18) const isValidUser = (value: unknown): value is { name: string; age: number } => isObject(value) && isString(value.name) && isNumber(value.age) && value.age > 18 // Mixed array const data: unknown[] = [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 17 }, 'invalid', { name: 'Charlie', age: 30 } ] const validUsers = R.filter(data, isValidUser) // => [{ name: 'Alice', age: 25 }, { name: 'Charlie', age: 30 }] // Type: { name: string; age: number }[] ``` --- ## Real-World Patterns ### Pattern 1: Form Validation ```typescript interface FormData { email: unknown age: unknown agreeToTerms: unknown } const validateForm = (form: FormData): Result => { if (!isString(form.email)) { return err('Email must be a string') } if (!isNumber(form.age) || form.age < 18) { return err('Age must be a number >= 18') } if (!isBoolean(form.agreeToTerms) || !form.agreeToTerms) { return err('Must agree to terms') } return ok({ email: form.email, age: form.age, agreeToTerms: form.agreeToTerms }) } ``` ### Pattern 2: Environment Variables ```typescript const getEnvNumber = (key: string): number | null => { const value = process.env[key] if (!isDefined(value)) return null const num = Number(value) return isFiniteNumber(num) ? num : null } const PORT = getEnvNumber('PORT') ?? 3000 const MAX_CONNECTIONS = getEnvNumber('MAX_CONNECTIONS') ?? 100 ``` ### Pattern 3: API Response Parsing ```typescript const parseGitHubUser = (data: unknown): Option => { if (!isObject(data)) return none() const { login, id, avatar_url } = data if (!isString(login) || !isNumber(id) || !isString(avatar_url)) { return none() } return some({ login, id, avatar_url }) } ``` ### Pattern 4: Safe JSON Parsing ```typescript const safeJsonParse = (json: string): Option => { try { const parsed = JSON.parse(json) return isDefined(parsed) ? some(parsed) : none() } catch { return none() } } ``` --- ## Performance Notes Type guards are extremely fast (simple type checks): ```typescript // βœ… Very fast - single typeof check isString(value) isNumber(value) isBoolean(value) // βœ… Fast - instanceof or Array.isArray check isDate(value) isError(value) isArray(value) // ⚠️ Slightly slower - object check + validation isObject(value) ``` --- ## Testing Type Guards ```typescript import { describe, it, expect } from 'vitest' import * as R from 'remeda' import { isString, isNumber, isDefined } from '@ontologyio/receta/predicate' describe('Type guards', () => { it('isString narrows to string', () => { const value: unknown = 'hello' expect(isString(value)).toBe(true) if (isString(value)) { // TypeScript compilation test - should not error const upper: string = value.toUpperCase() expect(upper).toBe('HELLO') } }) it('isNumber excludes NaN', () => { expect(isNumber(42)).toBe(true) expect(isNumber(NaN)).toBe(false) }) it('isDefined filters nullish', () => { const values = ['a', null, 'b', undefined] const result = R.filter(values, isDefined) expect(result).toEqual(['a', 'b']) }) }) ``` --- ## Next Steps - **[Common Patterns](./patterns.mdx)** - Complete real-world recipes - **[Migration Guide](./migration.mdx)** - Refactor inline checks to type guards - **[API Reference](../reference/predicate.mdx)** - Complete function listing --- # Migration Guide From Inline Filters to Predicates > Step-by-step guide to refactoring existing code to use composable predicates. ## Why Migrate? ### Before: Inline Filters ```typescript // ❌ Problems: const activeAdults = users.filter(u => u.age >= 18 && u.active === true) const activeStaff = users.filter(u => u.age >= 18 && u.active === true && (u.role === 'admin' || u.role === 'moderator')) const premiumUsers = users.filter(u => u.age >= 18 && u.active === true && u.premium) ``` **Issues:** - πŸ”΄ **Duplication**: "age >= 18 && active === true" repeated 3 times - πŸ”΄ **Not reusable**: Can't extract or compose logic - πŸ”΄ **Hard to test**: Logic buried in larger functions - πŸ”΄ **Not discoverable**: No central place for filters - πŸ”΄ **Type unsafe**: No narrowing for mixed arrays ### After: Predicates ```typescript // βœ… Benefits: import { where, and, gte, eq, oneOf } from '@ontologyio/receta/predicate' const isActiveAdult = where({ age: gte(18), active: eq(true) }) const isStaff = where({ role: oneOf(['admin', 'moderator']) }) const activeAdults = R.filter(users, isActiveAdult) const activeStaff = R.filter(users, and(isActiveAdult, isStaff)) const premiumUsers = R.filter(users, and(isActiveAdult, where({ premium: eq(true) }))) ``` **Wins:** - βœ… **No duplication**: Define once, use everywhere - βœ… **Composable**: Build complex from simple - βœ… **Testable**: Test predicates in isolation - βœ… **Discoverable**: All filters in one module - βœ… **Type safe**: Type guards narrow types --- ## Migration Strategy ### Phase 1: Identify Patterns Look for: 1. **Repeated conditions** across multiple filters 2. **Complex boolean logic** (multiple && or ||) 3. **Type checks** (typeof, instanceof) 4. **Value comparisons** (>, <, ===, includes()) 5. **Object property checks** (multiple property tests) ### Phase 2: Extract Simple Predicates Start with simple, reusable pieces. ### Phase 3: Compose Complex Predicates Combine simple predicates into complex ones. ### Phase 4: Refactor Call Sites Replace inline filters with named predicates. --- ## Step-by-Step Examples ### Example 1: Simple Numeric Filter #### Before ```typescript const expensiveProducts = products.filter(p => p.price > 100) const budgetProducts = products.filter(p => p.price <= 50) const midRangeProducts = products.filter(p => p.price >= 50 && p.price <= 200) ``` #### Step 1: Identify Pattern - Repeated: `p.price` comparisons - Pattern: Numeric threshold checks #### Step 2: Extract Predicates ```typescript import { gt, lte, between } from '@ontologyio/receta/predicate' const isExpensive = gt(100) const isBudget = lte(50) const isMidRange = between(50, 200) ``` #### Step 3: Refactor ```typescript const expensiveProducts = R.filter(products, (p) => isExpensive(p.price)) const budgetProducts = R.filter(products, (p) => isBudget(p.price)) const midRangeProducts = R.filter(products, (p) => isMidRange(p.price)) // Or with where(): const expensiveProducts2 = R.filter(products, where({ price: gt(100) })) const budgetProducts2 = R.filter(products, where({ price: lte(50) })) const midRangeProducts2 = R.filter(products, where({ price: between(50, 200) })) ``` --- ### Example 2: Status Filtering #### Before ```typescript const activeOrders = orders.filter(o => o.status === 'pending' || o.status === 'processing' || o.status === 'shipped' ) const completedOrders = orders.filter(o => o.status === 'delivered' || o.status === 'cancelled' ) ``` #### Step 1: Extract ```typescript import { oneOf } from '@ontologyio/receta/predicate' const isActiveStatus = oneOf(['pending', 'processing', 'shipped']) const isCompletedStatus = oneOf(['delivered', 'cancelled']) ``` #### Step 2: Refactor ```typescript const activeOrders = R.filter(orders, (o) => isActiveStatus(o.status)) const completedOrders = R.filter(orders, (o) => isCompletedStatus(o.status)) // Or with where(): const activeOrders2 = R.filter(orders, where({ status: isActiveStatus })) const completedOrders2 = R.filter(orders, where({ status: isCompletedStatus })) ``` --- ### Example 3: Multi-Field Object Filter #### Before ```typescript const eligibleUsers = users.filter(u => u.age >= 18 && u.verified === true && u.country === 'US' && (u.role === 'admin' || u.role === 'moderator') ) ``` #### Step 1: Break Down - Age check: `u.age >= 18` - Verified: `u.verified === true` - Country: `u.country === 'US'` - Role: `u.role === 'admin' || u.role === 'moderator'` #### Step 2: Extract ```typescript import { where, gte, eq, oneOf } from '@ontologyio/receta/predicate' const isEligible = where({ age: gte(18), verified: eq(true), country: eq('US'), role: oneOf(['admin', 'moderator']) }) ``` #### Step 3: Refactor ```typescript const eligibleUsers = R.filter(users, isEligible) ``` --- ### Example 4: Type Checking #### Before ```typescript const data: unknown[] = [/* mixed types */] const strings = data.filter(x => typeof x === 'string') const numbers = data.filter(x => typeof x === 'number' && !isNaN(x)) const validObjects = data.filter(x => typeof x === 'object' && x !== null && !Array.isArray(x) ) ``` #### Step 1: Use Type Guards ```typescript import { isString, isNumber, isObject } from '@ontologyio/receta/predicate' const strings = R.filter(data, isString) // Type: string[] const numbers = R.filter(data, isNumber) // Type: number[] const validObjects = R.filter(data, isObject) // Type: Record[] ``` **Bonus**: TypeScript automatically narrows types! --- ### Example 5: Complex Boolean Logic #### Before ```typescript const canEdit = users.filter(u => u.id === post.authorId || (u.role === 'admin' && u.verified) || (u.role === 'moderator' && u.reputation > 1000) ) ``` #### Step 1: Break Into Pieces ```typescript import { where, eq, and, or, gt } from '@ontologyio/receta/predicate' const isAuthor = where({ id: eq(post.authorId) }) const isVerifiedAdmin = where({ role: eq('admin'), verified: eq(true) }) const isSeniorMod = where({ role: eq('moderator'), reputation: gt(1000) }) ``` #### Step 2: Compose ```typescript const canEditPost = or(isAuthor, isVerifiedAdmin, isSeniorMod) ``` #### Step 3: Refactor ```typescript const canEdit = R.filter(users, canEditPost) ``` --- ### Example 6: Nested Property Access #### Before ```typescript const usOrders = orders.filter(o => o.customer.country === 'US') const verifiedOrders = orders.filter(o => o.customer.verified === true) const usVerifiedOrders = orders.filter(o => o.customer.country === 'US' && o.customer.verified === true ) ``` #### Step 1: Extract ```typescript import { where, eq } from '@ontologyio/receta/predicate' const hasUSCustomer = where({ customer: where({ country: eq('US') }) }) const hasVerifiedCustomer = where({ customer: where({ verified: eq(true) }) }) const hasUSVerifiedCustomer = where({ customer: where({ country: eq('US'), verified: eq(true) }) }) ``` #### Step 2: Refactor ```typescript const usOrders = R.filter(orders, hasUSCustomer) const verifiedOrders = R.filter(orders, hasVerifiedCustomer) const usVerifiedOrders = R.filter(orders, hasUSVerifiedCustomer) ``` --- ### Example 7: Derived Values #### Before ```typescript const popularUsers = users.filter(u => u.followers.length > 1000) const recentPosts = posts.filter(p => { const daysSince = (Date.now() - p.createdAt.getTime()) / (24 * 60 * 60 * 1000) return daysSince <= 7 }) ``` #### Step 1: Use `by()` ```typescript import { by, gt, lte } from '@ontologyio/receta/predicate' const isPopular = by((u: User) => u.followers.length, gt(1000)) const isRecent = by((p: Post) => { const daysSince = (Date.now() - p.createdAt.getTime()) / (24 * 60 * 60 * 1000) return daysSince }, lte(7)) ``` #### Step 2: Refactor ```typescript const popularUsers = R.filter(users, isPopular) const recentPosts = R.filter(posts, isRecent) ``` --- ## Incremental Migration You don't need to refactor everything at once! ### Strategy 1: Start with Duplicates Find the most duplicated filter logic and extract it first. ```typescript // 1. Find duplicates // ❌ Repeated everywhere: users.filter(u => u.age >= 18 && u.verified) // 2. Extract const isVerifiedAdult = where({ age: gte(18), verified: eq(true) }) // 3. Gradually replace call sites R.filter(users, isVerifiedAdult) ``` ### Strategy 2: Create a Predicates Module ```typescript // predicates/user.ts export const isAdult = where({ age: gte(18) }) export const isVerified = where({ verified: eq(true) }) export const isActive = where({ status: eq('active') }) export const isStaff = where({ role: oneOf(['admin', 'moderator']) }) // Compose export const isVerifiedAdult = and(isAdult, isVerified) export const isActiveStaff = and(isActive, isStaff) ``` ```typescript // Usage import * as UserPred from './predicates/user' R.filter(users, UserPred.isVerifiedAdult) R.filter(users, UserPred.isActiveStaff) ``` ### Strategy 3: Coexist During Migration Old and new can live side-by-side: ```typescript // βœ… Keep existing code working const oldFilter = users.filter(u => u.age >= 18) // βœ… New code uses predicates const newFilter = R.filter(users, where({ age: gte(18) })) ``` --- ## Common Pitfalls ### Pitfall 1: Over-Abstraction ```typescript // ❌ Too granular const isAgeGreaterThan18 = gt(18) const hasAgeGreaterThan18 = where({ age: isAgeGreaterThan18 }) // βœ… Right level const isAdult = where({ age: gt(18) }) ``` **Rule**: Abstract when logic is **reused**, not just for the sake of it. ### Pitfall 2: Forgetting Short-Circuit Order ```typescript // ❌ Expensive check first and( complexDatabaseQuery, // Slow! simplePropertyCheck // Fast ) // βœ… Fast check first and( simplePropertyCheck, complexDatabaseQuery // Only runs if first passes ) ``` ### Pitfall 3: Not Using Built-Ins ```typescript // ❌ Reinventing the wheel const notEmpty = (s: string) => s.length > 0 // βœ… Use built-in import { isNotEmpty } from '@ontologyio/receta/predicate' ``` ### Pitfall 4: Inline Predicates Everywhere ```typescript // ❌ Still inline, just with predicate functions R.filter(users, where({ age: gt(18), verified: eq(true) })) R.filter(users, where({ age: gt(18), verified: eq(true) })) R.filter(users, where({ age: gt(18), verified: eq(true) })) // βœ… Extract if reused const isVerifiedAdult = where({ age: gt(18), verified: eq(true) }) R.filter(users, isVerifiedAdult) R.filter(users, isVerifiedAdult) R.filter(users, isVerifiedAdult) ``` --- ## Testing After Migration ### Before (Hard to Test) ```typescript // Can't test filter logic in isolation const getActiveUsers = (users: User[]) => users.filter(u => u.age >= 18 && u.verified && u.status === 'active') ``` ### After (Easy to Test) ```typescript import { describe, it, expect } from 'vitest' // Test predicates independently describe('User predicates', () => { it('isActiveUser filters correctly', () => { const user1 = { age: 25, verified: true, status: 'active' } const user2 = { age: 17, verified: true, status: 'active' } const user3 = { age: 25, verified: false, status: 'active' } expect(isActiveUser(user1)).toBe(true) expect(isActiveUser(user2)).toBe(false) // Too young expect(isActiveUser(user3)).toBe(false) // Not verified }) }) // Test composition describe('User filtering', () => { it('getActiveUsers uses isActiveUser predicate', () => { const users = [/* ... */] const result = R.filter(users, isActiveUser) expect(result).toHaveLength(2) }) }) ``` --- ## Checklist Use this checklist when migrating code: - [ ] Identify duplicated filter logic - [ ] Extract simple comparison predicates (gt, eq, oneOf, etc.) - [ ] Create object predicates with where() for multi-field checks - [ ] Use type guards (isString, isNumber, etc.) for type checks - [ ] Compose complex predicates with and/or/not - [ ] Create a predicates module for domain logic - [ ] Write tests for extracted predicates - [ ] Update call sites to use new predicates - [ ] Remove old inline filters - [ ] Document predicate meanings --- ## Migration Examples by Domain ### E-Commerce ```typescript // Before products.filter(p => p.price > 100 && p.inStock && p.rating >= 4.0) // After const qualityExpensive = where({ price: gt(100), inStock: eq(true), rating: gte(4.0) }) ``` ### User Management ```typescript // Before users.filter(u => (u.role === 'admin' || u.role === 'mod') && u.verified) // After const isVerifiedStaff = where({ role: oneOf(['admin', 'mod']), verified: eq(true) }) ``` ### API Filtering ```typescript // Before issues.filter(i => i.state === 'open' && i.labels.includes('bug') && i.assignee !== null) // After const openAssignedBugs = where({ state: eq('open'), labels: (labels: string[]) => labels.includes('bug'), assignee: not(isNull) }) ``` --- ## Next Steps - **[API Reference](../reference/predicate.mdx)** - Complete function reference - **[Common Patterns](./patterns.mdx)** - Real-world examples for inspiration - **[README](./index.mdx)** - Documentation overview --- # Common Patterns & Recipes > Complete, copy-paste-ready solutions for real-world scenarios. ## Table of Contents 1. [E-Commerce Filtering](#e-commerce-filtering) 2. [User Permissions & Access Control](#user-permissions--access-control) 3. [API Filtering (GitHub/Stripe Style)](#api-filtering-githubstripe-style) 4. [Form Validation](#form-validation) 5. [Search & Query Functionality](#search--query-functionality) 6. [Database-Like Queries](#database-like-queries) 7. [Batch Processing & Data Pipelines](#batch-processing--data-pipelines) 8. [Content Moderation](#content-moderation) --- ## E-Commerce Filtering ### Product Search & Discovery ```typescript import * as R from 'remeda' import { where, between, gt, gte, eq, oneOf, by, and, or } from '@ontologyio/receta/predicate' interface Product { id: string name: string price: number category: string rating: number reviewCount: number inStock: boolean quantity: number salesLast30Days: number tags: string[] brand: string discount: number // 0.0 to 1.0 } // Price slider: $50-200 const priceRange = where({ price: between(50, 200) }) // Quality products (rating >= 4, enough reviews) const qualityProduct = where({ rating: gte(4.0), reviewCount: gte(10) }) // Available products const available = where({ inStock: eq(true) }) // Electronics category const electronics = where({ category: eq('electronics') }) // On sale (discount > 0) const onSale = where({ discount: gt(0) }) // Combine for "Best Electronics Deals" const bestElectronicsDeals = and( electronics, qualityProduct, available, onSale, priceRange ) const deals = R.filter(products, bestElectronicsDeals) ``` ### Dynamic Search Filters ```typescript interface SearchFilters { category?: string minPrice?: number maxPrice?: number minRating?: number brands?: string[] onlyInStock?: boolean tags?: string[] } const buildProductFilter = (filters: SearchFilters): Predicate => { const predicates: Predicate[] = [] if (filters.category) { predicates.push(where({ category: eq(filters.category) })) } if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { const min = filters.minPrice ?? 0 const max = filters.maxPrice ?? Infinity predicates.push(where({ price: between(min, max) })) } if (filters.minRating) { predicates.push(where({ rating: gte(filters.minRating) })) } if (filters.brands && filters.brands.length > 0) { predicates.push(where({ brand: oneOf(filters.brands) })) } if (filters.onlyInStock) { predicates.push(where({ inStock: eq(true) })) } if (filters.tags && filters.tags.length > 0) { predicates.push( where({ tags: (productTags: string[]) => filters.tags!.some(tag => productTags.includes(tag)) }) ) } return predicates.length > 0 ? and(...predicates) : always() } // Usage const searchResults = R.filter( products, buildProductFilter({ category: 'electronics', minPrice: 100, maxPrice: 500, minRating: 4.0, brands: ['Apple', 'Samsung'], onlyInStock: true }) ) ``` ### Inventory Management ```typescript // Low stock alert (< 10 units) const lowStock = where({ quantity: lt(10), inStock: eq(true) }) // Out of stock but popular (needs reorder) const needsRestock = where({ inStock: eq(false), rating: gte(4.0), reviewCount: gte(50) }) // Overstock (> 100 units, low sales) const overstock = and( where({ quantity: gt(100) }), by((p: Product) => p.salesLast30Days, lt(5)) ) ``` --- ## User Permissions & Access Control ### Role-Based Access Control ```typescript interface User { id: string role: 'user' | 'moderator' | 'admin' | 'owner' verified: boolean accountAgeDays: number reputation: number permissions: string[] flags: string[] tier: 'free' | 'pro' | 'enterprise' } // Basic roles const isAdmin = where({ role: eq('admin') }) const isModerator = where({ role: eq('moderator') }) const isOwner = where({ role: eq('owner') }) // Staff members (admins or moderators) const isStaff = where({ role: oneOf(['admin', 'moderator', 'owner']) }) // Verified users const isVerified = where({ verified: eq(true) }) // Veteran users (account > 1 year) const isVeteran = where({ accountAgeDays: gte(365) }) // High reputation users const highReputation = where({ reputation: gte(1000) }) // Premium users const isPremium = where({ tier: oneOf(['pro', 'enterprise']) }) ``` ### Complex Permission Logic ```typescript // Can moderate content const canModerate = or( isAdmin, and(isModerator, isVerified) ) // Can edit any post const canEditAnyPost = or( isAdmin, and(isModerator, isVerified, highReputation) ) // Can access admin panel const canAccessAdminPanel = or( isAdmin, and(isModerator, isVeteran, isVerified) ) // Can create premium content const canCreatePremiumContent = or( isPremium, isStaff ) // Can invite users const canInviteUsers = or( isStaff, and(isVerified, highReputation, isVeteran) ) // Trusted user (can skip moderation) const isTrusted = or( isStaff, and( isVerified, highReputation, where({ accountAgeDays: gte(180) }), where({ flags: by((flags: string[]) => flags.length, eq(0)) }) ) ) ``` ### Resource-Level Permissions ```typescript interface Resource { id: string ownerId: string public: boolean collaborators: string[] } const canView = (user: User, resource: Resource): boolean => or( // Owner can always view where({ id: eq(resource.ownerId) }), // Public resources () => resource.public, // Collaborators (u) => resource.collaborators.includes(u.id), // Admins can view everything isAdmin )(user) const canEdit = (user: User, resource: Resource): boolean => or( // Owner can edit where({ id: eq(resource.ownerId) }), // Admins can edit isAdmin, // Verified collaborators can edit and( (u) => resource.collaborators.includes(u.id), isVerified ) )(user) const canDelete = (user: User, resource: Resource): boolean => or( where({ id: eq(resource.ownerId) }), isAdmin )(user) ``` --- ## API Filtering (GitHub/Stripe Style) ### GitHub Issues Filtering ```typescript interface Issue { id: number number: number state: 'open' | 'closed' title: string body: string labels: string[] assignee: string | null milestone: string | null comments: number createdAt: Date updatedAt: Date author: string } // Open issues const open = where({ state: eq('open') }) // Assigned issues const assigned = where({ assignee: isDefined }) // Unassigned issues const unassigned = where({ assignee: isNull }) // Has specific label const hasLabel = (label: string) => where({ labels: (labels: string[]) => labels.includes(label) }) // Bugs const isBug = hasLabel('bug') // Features const isFeature = hasLabel('feature') // Critical issues const isCritical = hasLabel('critical') // Needs triage (open, no labels, no assignee) const needsTriage = and( open, unassigned, where({ labels: (labels: string[]) => labels.length === 0 }) ) // Stale issues (open, no activity in 90 days) const isStale = and( open, where({ updatedAt: (date: Date) => { const ninetyDaysAgo = Date.now() - 90 * 24 * 60 * 60 * 1000 return date.getTime() < ninetyDaysAgo } }) ) // High priority bugs (open, bug label, critical or many comments) const highPriorityBugs = and( open, isBug, or( isCritical, where({ comments: gte(10) }) ) ) ``` ### Stripe Charges Filtering ```typescript interface Charge { id: string amount: number // cents currency: string status: 'succeeded' | 'pending' | 'failed' paid: boolean refunded: boolean disputed: boolean customerId: string created: number // unix timestamp } // Successful charges const succeeded = where({ status: eq('succeeded'), paid: eq(true) }) // Failed charges const failed = where({ status: eq('failed') }) // Large charges (> $100) const largeCharges = where({ amount: gt(10000) // $100.00 in cents }) // Recent charges (last 30 days) const recent = where({ created: (timestamp: number) => { const thirtyDaysAgo = Date.now() / 1000 - 30 * 24 * 60 * 60 return timestamp > thirtyDaysAgo } }) // Refunded charges const refunded = where({ refunded: eq(true) }) // Disputed charges const disputed = and( succeeded, where({ disputed: eq(true) }) ) ``` --- ## Form Validation ### Signup Form Validation ```typescript interface SignupForm { email: string password: string confirmPassword: string age: number agreeToTerms: boolean newsletter: boolean } // Email validation const isValidEmail = matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) // Password requirements const isStrongPassword = and( by((s: string) => s.length, gte(8)), matches(/[A-Z]/), // Has uppercase matches(/[a-z]/), // Has lowercase matches(/[0-9]/), // Has number matches(/[!@#$%^&*]/) // Has special char ) // Age validation const isAdult = gte(18) // Validate entire form const validateSignup = (form: SignupForm): string[] => { const errors: string[] = [] if (!isValidEmail(form.email)) { errors.push('Invalid email address') } if (!isStrongPassword(form.password)) { errors.push('Password must be 8+ chars with uppercase, lowercase, number, and special char') } if (form.password !== form.confirmPassword) { errors.push('Passwords do not match') } if (!isAdult(form.age)) { errors.push('Must be 18 or older') } if (!form.agreeToTerms) { errors.push('Must agree to terms and conditions') } return errors } ``` ### Multi-Step Form Validation ```typescript interface Step1Data { firstName: string lastName: string email: string } interface Step2Data { address: string city: string zipCode: string country: string } interface Step3Data { cardNumber: string cvv: string expiryDate: string } const validateStep1 = where({ firstName: by((s: string) => s.length, between(2, 50)), lastName: by((s: string) => s.length, between(2, 50)), email: matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) }) const validateStep2 = where({ address: isNotEmpty, city: isNotEmpty, zipCode: matches(/^\d{5}(-\d{4})?$/), country: oneOf(['US', 'CA', 'UK', 'AU']) }) const validateStep3 = where({ cardNumber: matches(/^\d{16}$/), cvv: matches(/^\d{3,4}$/), expiryDate: matches(/^\d{2}\/\d{2}$/) }) ``` --- ## Search & Query Functionality ### Full-Text Search with Filters ```typescript interface Article { id: string title: string content: string author: string tags: string[] publishedAt: Date viewCount: number featured: boolean } const searchArticles = ( articles: Article[], query: string, filters?: { author?: string tags?: string[] onlyFeatured?: boolean minViews?: number } ) => { const searchTerm = query.toLowerCase() // Text search predicate const matchesSearch = or( by((a: Article) => a.title.toLowerCase(), includes(searchTerm)), by((a: Article) => a.content.toLowerCase(), includes(searchTerm)) ) // Build filter predicates const predicates: Predicate
[] = [matchesSearch] if (filters?.author) { predicates.push(where({ author: eq(filters.author) })) } if (filters?.tags && filters.tags.length > 0) { predicates.push( where({ tags: (articleTags: string[]) => filters.tags!.some(tag => articleTags.includes(tag)) }) ) } if (filters?.onlyFeatured) { predicates.push(where({ featured: eq(true) })) } if (filters?.minViews) { predicates.push(where({ viewCount: gte(filters.minViews) })) } return R.filter(articles, and(...predicates)) } ``` ### Faceted Search ```typescript // Count results per facet const getFacetCounts = (products: Product[]) => { const categories = R.pipe( products, R.groupBy((p) => p.category), R.mapValues((items) => items.length) ) const brands = R.pipe( products, R.groupBy((p) => p.brand), R.mapValues((items) => items.length) ) const priceRanges = { under50: R.filter(products, where({ price: lt(50) })).length, '50to100': R.filter(products, where({ price: between(50, 100) })).length, '100to200': R.filter(products, where({ price: between(100, 200) })).length, over200: R.filter(products, where({ price: gt(200) })).length } return { categories, brands, priceRanges } } ``` --- ## Database-Like Queries ### SQL-Style Queries ```typescript // SQL: SELECT * FROM orders WHERE status IN ('pending', 'processing') AND amount > 1000 const query1 = where({ status: oneOf(['pending', 'processing']), amount: gt(1000) }) // SQL: SELECT * FROM users WHERE role = 'admin' AND verified = true AND age >= 18 const query2 = where({ role: eq('admin'), verified: eq(true), age: gte(18) }) // SQL: SELECT * FROM products WHERE category = 'electronics' AND (price < 100 OR discount > 0.2) const query3 = and( where({ category: eq('electronics') }), or( where({ price: lt(100) }), where({ discount: gt(0.2) }) ) ) ``` ### JOIN-Like Operations ```typescript interface User { id: string name: string countryCode: string } interface Country { code: string name: string region: 'EU' | 'NA' | 'APAC' } // Find users in EU countries const euCountryCodes = R.pipe( countries, R.filter(where({ region: eq('EU') })), R.map((c) => c.code) ) const euUsers = R.filter( users, where({ countryCode: oneOf(euCountryCodes) }) ) ``` --- ## Batch Processing & Data Pipelines ### Data Quality Checks ```typescript interface DataRecord { id: string value: number timestamp: Date source: string validated: boolean } // Valid records (non-negative value, recent, validated) const isValid = where({ value: gte(0), timestamp: (date: Date) => { const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 return date.getTime() > oneDayAgo }, validated: eq(true) }) // Suspicious records (extreme values) const isSuspicious = or( where({ value: lt(0) }), where({ value: gt(1000000) }) ) // Partition data const [valid, invalid] = R.partition(records, isValid) const suspicious = R.filter(records, isSuspicious) ``` ### ETL Pipeline ```typescript const processRecords = (rawData: unknown[]) => { // Step 1: Filter valid objects const objects = R.filter(rawData, isObject) // Step 2: Parse and validate const parsed: ParsedRecord[] = R.pipe( objects, R.map(parseRecord), R.filter(isDefined) ) // Step 3: Apply business rules const validRecords = R.filter( parsed, where({ amount: and(gte(0), lt(1000000)), status: oneOf(['active', 'pending']), timestamp: (d: Date) => !isNaN(d.getTime()) }) ) return validRecords } ``` --- ## Content Moderation ```typescript interface Post { id: string content: string authorId: string flagCount: number reportedReasons: string[] createdAt: Date } // Needs review (multiple flags or serious reports) const needsReview = or( where({ flagCount: gte(3) }), where({ reportedReasons: (reasons: string[]) => reasons.some(r => ['spam', 'hate', 'violence'].includes(r)) }) ) // Auto-approve (trusted author, no flags) const autoApprove = and( where({ flagCount: eq(0) }), where({ authorId: (id: string) => trustedAuthors.includes(id) }) ) // Needs manual review (somewhere in between) const manualReview = and( not(autoApprove), not(needsReview) ) ``` --- ## Next Steps - **[Migration Guide](./migration.mdx)** - Refactor existing code to use predicates - **[API Reference](../reference/predicate.mdx)** - Complete function reference --- # Async 35 exports from @ontologyio/receta/async. Everything exported from `@ontologyio/receta/async`. New here? Start with the [Async guide](/docs/async). ## Functions ### batch Processes items in batches with optional delay between batches. Returns a Result to handle errors explicitly. If any batch processing fails, the entire operation returns an Err with details about which batch failed. `fn` runs once per batch and receives the whole batch, so the result holds one entry per batch rather than one per item. Useful for bulk operations like data imports, exports, or processing large datasets without overwhelming the system. ```ts title="Signature" function batch(items: readonly T[], fn: (batch: readonly T[]) => Promise, options?: BatchOptions): Promise> ``` **Fails with** `BatchError`. ```ts title="Example" import * as R from 'remeda' import { unwrapOr, tapErr } from '@ontologyio/receta/result' // Process users in batches of 50 const result = await batch( users, async (userBatch) => { return db.users.insertMany(userBatch) }, { batchSize: 50, delayBetweenBatches: 1000, // 1 second between batches } ) // Handle with Result pattern const logged = tapErr(result, error => console.error('Batch failed:', error)) const inserted = unwrapOr(logged, []) // Send emails in batches to avoid rate limits const emailResult = await batch( emailAddresses, async (batch) => sendBulkEmail(batch), { batchSize: 100, delayBetweenBatches: 5000, // 5 seconds between batches } ) // Import large dataset with error handling const imported = await batch( records, async (recordBatch) => { console.log(`Importing batch of ${recordBatch.length}`) return importRecords(recordBatch) }, { batchSize: 1000, concurrency: 2 } // Process 2 batches concurrently ) ``` **See also** `mapAsync`, `parallel`, `batchOrThrow`. ### batchOrThrow Throwing variant of batch for backward compatibility. Use this when you want exceptions instead of Result pattern. Prefer the Result-returning batch for better error handling. ```ts title="Signature" function batchOrThrow(items: readonly T[], fn: (batch: readonly T[]) => Promise, options?: BatchOptions | undefined): Promise ``` ```ts title="Example" // Throws on error try { const results = await batchOrThrow( users, async (userBatch) => db.users.insertMany(userBatch), { batchSize: 50 } ) } catch (error) { console.error('Batch failed:', error) } ``` **See also** `batch`, `orThrow`. ### chunk Split an array into groups the length of `size`. If `array` can't be split evenly, the final chunk will be the remaining elements. ```ts title="Data-first" function chunk(array: T, size: N): Chunk ``` ```ts title="Data-last" function chunk(size: N): (array: T) => Chunk ``` ```ts title="Example" chunk(['a', 'b', 'c', 'd'], 2) // => [['a', 'b'], ['c', 'd']] chunk(['a', 'b', 'c', 'd'], 3) // => [['a', 'b', 'c'], ['d']] ``` ```ts title="Example" chunk(2)(['a', 'b', 'c', 'd']) // => [['a', 'b'], ['c', 'd']] chunk(3)(['a', 'b', 'c', 'd']) // => [['a', 'b', 'c'], ['d']] ``` ### debounce Debounces an async function. Creates a debounced version of an async function that delays invoking until after the specified delay has elapsed since the last call. Useful for rate-limiting API calls, search inputs, or any operation that shouldn't run on every invocation. Every call settles. A call that a newer one replaces gets the result of the run that replaced it, and with `trailing: false`, a call ignored during the delay gets the result of the last leading run. If `leading` and `trailing` are both false, nothing can run, so every call rejects. There is no `cancel` or `flush`. ```ts title="Signature" function debounce(fn: (...args: TArgs) => Promise, options: DebounceOptions): (...args: TArgs) => Promise ``` ```ts title="Example" // Debounced search const searchAPI = debounce( async (query: string) => { const res = await fetch(`/api/search?q=${query}`) return res.json() }, { delay: 300 } ) // User types "hello" quickly searchAPI('h') // Replaced, settles with the result for 'hello' searchAPI('he') // Replaced searchAPI('hel') // Replaced searchAPI('hell') // Replaced searchAPI('hello') // Executes after 300ms // Leading edge debounce (execute immediately, ignore subsequent calls) const saveData = debounce( async (data) => api.save(data), { delay: 1000, leading: true, trailing: false } ) ``` **See also** `throttle`. ### extractFulfilled Extracts all fulfilled values from settled results. Filters out rejected promises and returns only successful values. Useful when you want to process partial results even if some promises failed. ```ts title="Signature" function extractFulfilled(results: readonly PromiseSettledResult[]): T[] ``` ```ts title="Example" const results = await promiseAllSettled([ Promise.resolve(1), Promise.reject('error'), Promise.resolve(3), ]) const values = extractFulfilled(results) // => [1, 3] ``` ```ts title="Example" // Process partial API results const results = await promiseAllSettled( userIds.map((id) => fetchUser(id)) ) const successfulUsers = extractFulfilled(results) console.log(`Fetched ${successfulUsers.length} of ${userIds.length} users`) ``` ### extractRejected Extracts all rejection reasons from settled results. Filters out fulfilled promises and returns only error reasons. Useful for logging or analyzing failures. ```ts title="Signature" function extractRejected(results: readonly PromiseSettledResult[]): T[] ``` ```ts title="Example" const results = await promiseAllSettled([ Promise.resolve(1), Promise.reject(new Error('Network error')), Promise.reject(new Error('Timeout')), ]) const errors = extractRejected(results) // => [Error('Network error'), Error('Timeout')] errors.forEach((err) => console.error('Failed:', err.message)) ``` ```ts title="Example" // Track failed operations const results = await promiseAllSettled( userIds.map((id) => deleteUser(id)) ) const failures = extractRejected(results) if (failures.length > 0) { console.warn(`${failures.length} deletions failed:`, failures) } ``` ### filterAsync Filters an array using an async predicate function. Returns a Result to handle errors explicitly. If any predicate evaluation fails, the entire operation returns an Err with details about the failure. ```ts title="Data-first" function filterAsync(items: readonly T[], predicate: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): Promise> ``` ```ts title="Data-last" function filterAsync(predicate: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): (items: readonly T[]) => Promise> ``` **Fails with** `FilterAsyncError`. ```ts title="Example" import * as R from 'remeda' import { unwrapOr, tapErr } from '@ontologyio/receta/result' // Data-first: Filter users who exist const result = await filterAsync( userIds, async (id) => { const res = await fetch(`/api/users/${id}`) return res.ok }, { concurrency: 5 } ) // Handle with Result pattern const logged = tapErr(result, error => console.error('Filter failed:', error)) const existingUsers = unwrapOr(logged, []) // Data-last (pipeAsync awaits each step) const filtered = await pipeAsync( urls, filterAsync(isReachable, { concurrency: 3 }), (result) => unwrapOr(result, []) ) ``` **See also** `mapAsync`, `filterAsyncOrThrow`. ### filterAsyncOrThrow Throwing variant of filterAsync for backward compatibility. Use this when you want exceptions instead of Result pattern. Prefer the Result-returning filterAsync for better error handling. ```ts title="Data-first" function filterAsyncOrThrow(items: readonly T[], predicate: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): Promise ``` ```ts title="Data-last" function filterAsyncOrThrow(predicate: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): (items: readonly T[]) => Promise ``` ```ts title="Example" // Throws on error try { const existingUsers = await filterAsyncOrThrow( userIds, async (id) => { const res = await fetch(`/api/users/${id}`) return res.ok }, { concurrency: 5 } ) } catch (error) { console.error('Filter failed:', error) } ``` **See also** `filterAsync`, `orThrow`. ### mapAsync Maps over an array with an async function, with optional concurrency control. Returns a Result to handle errors explicitly. If any mapper function fails, the entire operation returns an Err with details about the failure. This is more powerful than Promise.all(items.map(fn)) because it: - Allows limiting concurrent operations (rate limiting, resource management) - Returns Result for explicit error handling - Provides detailed error information (which item failed, when `concurrency` is below the item count) ```ts title="Data-first" function mapAsync(items: readonly T[], fn: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): Promise> ``` ```ts title="Data-last" function mapAsync(fn: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): (items: readonly T[]) => Promise> ``` **Fails with** `MapAsyncError`. ```ts title="Example" import * as R from 'remeda' import { unwrapOr, tapErr } from '@ontologyio/receta/result' // Data-first: Fetch users with concurrency limit const result = await mapAsync( ['user1', 'user2', 'user3'], async (id) => fetch(`/api/users/${id}`).then(r => r.json()), { concurrency: 2 } ) // Handle with Result pattern const logged = tapErr(result, error => console.error('Failed at index:', error.index)) const users = unwrapOr(logged, []) // Data-last (pipeAsync awaits each step) const processed = await pipeAsync( userIds, mapAsync(fetchUser, { concurrency: 5 }), (result) => unwrapOr(result, []) ) // Unlimited concurrency (same as Promise.all but safer) const all = await mapAsync(urls, fetchJSON) ``` **See also** `filterAsync`, `parallel`, `mapAsyncOrThrow`. ### mapAsyncOrThrow Throwing variant of mapAsync for backward compatibility. Use this when you want exceptions instead of Result pattern. Prefer the Result-returning mapAsync for better error handling. This is implemented using the `orThrow` utility from Result module, which converts any Result-returning function to a throwing variant. ```ts title="Data-first" function mapAsyncOrThrow(items: readonly T[], fn: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): Promise ``` ```ts title="Data-last" function mapAsyncOrThrow(fn: (item: T, index: number) => Promise, options?: ConcurrencyOptions | undefined): (items: readonly T[]) => Promise ``` ```ts title="Example" // Throws on error try { const users = await mapAsyncOrThrow( userIds, async (id) => fetchUser(id), { concurrency: 5 } ) } catch (error) { console.error('Failed:', error) } ``` **See also** `mapAsync`, `orThrow`. ### parallel Executes an array of async tasks in parallel with optional concurrency limit. Similar to Promise.all but with concurrency control, and it returns a Result instead of rejecting. Useful when you have a list of async operations and want to limit how many run simultaneously. ```ts title="Signature" function parallel(tasks: readonly (() => Promise)[], options?: ConcurrencyOptions | undefined): Promise> ``` **Fails with** `MapAsyncError`. ```ts title="Example" // Execute all tasks with concurrency limit of 3 const results = await parallel( [ () => fetch('/api/users/1'), () => fetch('/api/users/2'), () => fetch('/api/users/3'), () => fetch('/api/users/4'), () => fetch('/api/users/5'), ], { concurrency: 3 } ) // Unlimited concurrency (same as Promise.all) const all = await parallel([ () => fetchUser(1), () => fetchPosts(), () => fetchComments(), ]) // Generate tasks dynamically const userIds = [1, 2, 3, 4, 5] const fetchTasks = userIds.map(id => () => fetchUser(id)) const users = await parallel(fetchTasks, { concurrency: 2 }) ``` **See also** `sequential`, `mapAsync`. ### parallelOrThrow Throwing variant of parallel for backward compatibility. Use this when you want exceptions instead of Result pattern. Prefer the Result-returning parallel for better error handling. This is implemented using the `orThrow` utility from Result module, which converts any Result-returning function to a throwing variant. ```ts title="Signature" function parallelOrThrow(tasks: readonly (() => Promise)[], options?: ConcurrencyOptions | undefined): Promise ``` ```ts title="Example" // Equivalent to using orThrow directly const parallelOrThrow = orThrow(parallel) ``` **See also** `parallel`, `orThrow`. ### pipeAsync Composes async functions from left to right. Like Remeda's `pipe`, but handles async functions by awaiting each step before passing the result to the next function. Useful for chaining async transformations in a readable, sequential manner. **Note**: All functions are executed sequentially (not in parallel). For parallel execution, use `parallel` or `mapAsync`. ```ts title="Overload 1" function pipeAsync(value: A, fn1: (a: A) => B | Promise): Promise ``` ```ts title="Overload 2" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise): Promise ``` ```ts title="Overload 3" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise): Promise ``` ```ts title="Overload 4" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise): Promise ``` ```ts title="Overload 5" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise): Promise ``` ```ts title="Overload 6" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise, fn6: (f: F) => G | Promise): Promise ``` ```ts title="Overload 7" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise, fn6: (f: F) => G | Promise, fn7: (g: G) => H | Promise): Promise ``` ```ts title="Overload 8" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise, fn6: (f: F) => G | Promise, fn7: (g: G) => H | Promise, fn8: (h: H) => I | Promise): Promise ``` ```ts title="Overload 9" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise, fn6: (f: F) => G | Promise, fn7: (g: G) => H | Promise, fn8: (h: H) => I | Promise, fn9: (i: I) => J | Promise): Promise ``` ```ts title="Overload 10" function pipeAsync(value: A, fn1: (a: A) => B | Promise, fn2: (b: B) => C | Promise, fn3: (c: C) => D | Promise, fn4: (d: D) => E | Promise, fn5: (e: E) => F | Promise, fn6: (f: F) => G | Promise, fn7: (g: G) => H | Promise, fn8: (h: H) => I | Promise, fn9: (i: I) => J | Promise, fn10: (j: J) => K | Promise): Promise ``` ```ts title="Example" // Sequential API calls const result = await pipeAsync( userId, (id) => fetchUser(id), (user) => fetchUserPosts(user.id), (posts) => posts.filter(p => p.published), (posts) => posts.map(p => p.title) ) // => ['Post 1', 'Post 2', ...] ``` ```ts title="Example" // Mix sync and async functions const processOrder = await pipeAsync( orderId, (id) => fetchOrder(id), // async (order) => order.items, // sync async (items) => { // async const prices = await Promise.all(items.map(getPriceAsync)) return prices }, (prices) => prices.reduce((a, b) => a + b, 0) // sync ) ``` ```ts title="Example" // Error handling with Result pattern import { tryCatchAsync, map, unwrapOr } from '@ontologyio/receta/result' const safeResult = await pipeAsync( userId, (id) => tryCatchAsync(() => fetchUser(id)), (userResult) => map(userResult, u => u.email) ) const email = unwrapOr(safeResult, 'unknown@example.com') ``` ```ts title="Example" // Data transformation pipeline const report = await pipeAsync( 'raw-data.json', (file) => fs.readFile(file, 'utf-8'), (content) => JSON.parse(content), async (data) => { const enriched = await enrichData(data) return enriched }, (data) => generateReport(data) ) ``` **See also** `sequential`, `https://remedajs.com/docs#pipe`. ### poll Polls an async function until it returns a value or reaches max attempts. Useful for waiting on async operations like job completion, order status, or resource availability. Returns Result for explicit error handling. `null`, `undefined` and `false` mean "not yet". Anything else, including `0` and `''`, stops polling and becomes the Ok value. If `fn` throws, the returned promise rejects instead of resolving to an Err. ```ts title="Signature" function poll(fn: () => Promise, options?: PollOptions): Promise> ``` **Fails with** `PollError`. ```ts title="Example" import * as R from 'remeda' import { unwrapOr, mapErr } from '@ontologyio/receta/result' // Poll until job is complete const result = await poll( async () => { const status = await checkJobStatus(jobId) return status.state === 'completed' ? status : null }, { interval: 1000, // Check every second maxAttempts: 30, // Max 30 attempts (30 seconds) } ) // Handle with Result pattern const job = R.pipe( result, mapErr(error => { if (error.type === 'max_attempts') { return 'Job check timed out' } return 'Job check failed' }), unwrapOr(null) ) // Poll with timeout const order = await poll( async () => fetchOrderStatus(orderId), { interval: 2000, maxAttempts: 30, timeout: 60000, // Fail after 1 minute onPoll: (attempt) => console.log(`Checking... attempt ${attempt}`) } ) // Poll until condition is met const result = await poll( async () => { const data = await fetchData() return data.ready ? data : null }, { interval: 500, maxAttempts: 50, shouldContinue: (attempt) => attempt < 20 } ) ``` **See also** `retry`. ### promiseAllSettled Typed wrapper around Promise.allSettled with better type inference. Unlike Promise.all (which fails fast on first rejection), this waits for all promises to settle (fulfill or reject) before returning results. Provides helper functions to extract fulfilled/rejected values. ```ts title="Signature" function promiseAllSettled(promises: readonly Promise[]): Promise[]> ``` ```ts title="Example" // Basic usage const results = await promiseAllSettled([ fetch('/api/users/1'), fetch('/api/users/2'), fetch('/api/users/999'), // might fail ]) results.forEach((result) => { if (result.status === 'fulfilled') { console.log('Success:', result.value) } else { console.log('Error:', result.reason) } }) ``` ```ts title="Example" // Extract only successful results const results = await promiseAllSettled([ fetchUser(1), fetchUser(2), fetchUser(999), // fails ]) const users = extractFulfilled(results) // => [User1, User2] (failed one excluded) ``` ```ts title="Example" // Convert to Result pattern for composability import * as R from 'remeda' import { map, unwrapOr } from '@ontologyio/receta/result' const results = await promiseAllSettled([ fetchUser(1), fetchUser(2), fetchUser(3), ]) const resultArray = toResults(results) const usernames = R.pipe( resultArray, R.map((r) => map(r, (user) => user.name)), R.map((r) => unwrapOr(r, 'Unknown')) ) // => ['John', 'Jane', 'Unknown'] ``` **See also** `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled`. ### retry Retries an async function with exponential backoff, returning a Result. Useful for handling transient failures like network errors, rate limits, or temporary service unavailability. Returns Result for explicit error handling. `fn` runs again when it throws or rejects. A function that returns an Err has succeeded as far as retry can tell, so throw to ask for another attempt. ```ts title="Signature" function retry(fn: () => Promise, options?: RetryOptions): Promise> ``` **Fails with** `RetryError`. ```ts title="Example" import * as R from 'remeda' import { map, tapErr, unwrapOr } from '@ontologyio/receta/result' // Basic retry with defaults (3 attempts, exponential backoff) const result = await retry(async () => { const res = await fetch('/api/data') if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) // Handle with Result pattern const data = R.pipe( result, tapErr(error => console.error('Failed after retries:', error)), unwrapOr({ default: 'data' }) ) // Custom retry options const customResult = await retry( async () => fetchData(), { maxAttempts: 5, delay: 500, backoff: 2, maxDelay: 10000, jitter: 250, // add 0-250ms of random delay to avoid thundering herds shouldRetry: (error, attempt) => { // Only retry on specific errors return error instanceof NetworkError && attempt < 3 }, onRetry: (error, attempt, delay) => { console.log(`Retry ${attempt} after ${delay}ms:`, error) } } ) // Compose with other Result operations const processed = R.pipe( await retry(() => fetchUser(id)), map(user => user.email), unwrapOr('noreply@example.com') ) ``` **See also** `poll`, `timeout`. ### sequential Executes an array of async tasks sequentially (one at a time). Returns a Result to handle errors explicitly. If any task fails, execution stops and returns an Err with details about which task failed. Each task waits for the previous one to complete before starting. Useful when tasks have dependencies or when you need strict ordering. ```ts title="Signature" function sequential(tasks: readonly (() => Promise)[]): Promise> ``` **Fails with** `SequentialError`. ```ts title="Example" import * as R from 'remeda' import { isErr, tapErr, unwrapOr } from '@ontologyio/receta/result' // Execute tasks one at a time in order const result = await sequential([ () => createUser({ name: 'Alice' }), () => createPost({ title: 'Hello' }), () => publishPost({ id: 1 }), ]) // Handle with Result pattern const logged = tapErr(result, error => console.error('Task failed at index:', error.taskIndex)) const results = unwrapOr(logged, []) // Database migrations that must run in order const migrationResult = await sequential([ () => runMigration('001_create_users'), () => runMigration('002_add_email_index'), () => runMigration('003_create_posts'), ]) if (isErr(migrationResult)) { console.error('Migration failed:', migrationResult.error) // Can rollback completed migrations using completedTasks count } // Process items with side effects in order const logs = await sequential( events.map(event => () => processEvent(event)) ) ``` **See also** `parallel`, `sequentialOrThrow`. ### sequentialOrThrow Throwing variant of sequential for backward compatibility. Use this when you want exceptions instead of Result pattern. Prefer the Result-returning sequential for better error handling. ```ts title="Signature" function sequentialOrThrow(tasks: readonly (() => Promise)[]): Promise ``` ```ts title="Example" // Throws on error try { const results = await sequentialOrThrow([ () => createUser({ name: 'Alice' }), () => createPost({ title: 'Hello' }), () => publishPost({ id: 1 }), ]) } catch (error) { console.error('Task failed:', error) } ``` **See also** `sequential`, `orThrow`. ### sleep Sleep for a specified duration. ```ts title="Signature" function sleep(ms: number): Promise ``` ```ts title="Example" // Wait 1 second await sleep(1000) // Use in sequence console.log('Starting...') await sleep(2000) console.log('2 seconds later') ``` ### throttle Throttles an async function. Creates a throttled version of an async function that only invokes at most once per specified delay period. Unlike debounce (which delays execution until calls stop), throttle guarantees execution at regular intervals regardless of call frequency. Calls made during the delay share one promise for the next trailing run, which uses the latest arguments. There is no `cancel` or `flush`. ```ts title="Signature" function throttle(fn: (...args: TArgs) => Promise, options: ThrottleOptions): (...args: TArgs) => Promise ``` ```ts title="Example" // Throttled API call (max once per second) const trackEvent = throttle( async (event: string) => { await fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ event }) }) }, { delay: 1000 } ) // User scrolls rapidly trackEvent('scroll') // Executes immediately trackEvent('scroll') // Waits for the trailing run trackEvent('scroll') // Shares that run, which uses the latest arguments // ... 1 second passes, and the trailing run executes // Throttle with trailing edge only const saveProgress = throttle( async (data) => api.save(data), { delay: 5000, leading: false, trailing: true } ) ``` **See also** `debounce`. ### timeout Adds a timeout to a promise, returning a Result. If the promise doesn't resolve within the specified time, returns an Err with TimeoutError for explicit error handling. Only the timeout becomes an Err: if the promise rejects first, that rejection is rethrown. The promise keeps running after a timeout, because timeout stops waiting rather than cancelling the work. ```ts title="Signature" function timeout(promise: Promise, ms: number): Promise> ``` **Fails with** `TimeoutError`. ```ts title="Example" import * as R from 'remeda' import { isErr, map, tapErr, unwrapOr } from '@ontologyio/receta/result' // Basic timeout const result = await timeout( fetch('/api/data').then(r => r.json()), 5000 // 5 second timeout ) // Handle with Result pattern const data = R.pipe( result, tapErr(error => console.error('Timeout:', error)), unwrapOr({ fallback: 'data' }) ) // Compose with other async operations const processed = R.pipe( await timeout(fetchUser(id), 3000), map(user => user.email), unwrapOr('noreply@example.com') ) // Check timeout errors explicitly const result = await timeout(slowOperation(), 1000) if (isErr(result) && result.error instanceof TimeoutError) { console.log('Operation timed out') } ``` **See also** `retry`, `poll`. ### toResults Converts settled results to Result array for composability. Transforms native PromiseSettledResult into Receta's Result type, enabling composition with Result utilities (map, flatMap, etc.). ```ts title="Signature" function toResults(results: readonly PromiseSettledResult[]): Result[] ``` ```ts title="Example" import * as R from 'remeda' import { map, unwrapOr } from '@ontologyio/receta/result' const results = await promiseAllSettled([ fetchUser(1), fetchUser(999), // fails ]) const userResults = toResults(results) const names = R.pipe( userResults, R.map((r) => map(r, (user) => user.name)), R.map((r) => unwrapOr(r, 'Unknown')) ) // => ['John', 'Unknown'] ``` ```ts title="Example" import { partition } from '@ontologyio/receta/result' const results = await promiseAllSettled([ fetchUser(1), fetchUser(2), fetchUser(999), ]) const [successes, failures] = partition(toResults(results)) console.log(`${successes.length} succeeded, ${failures.length} failed`) ``` ```ts title="Example" // Compose with validation import { flatMap } from '@ontologyio/receta/result' import { validate } from '@ontologyio/receta/validation' const results = await promiseAllSettled( urls.map((url) => fetch(url).then(r => r.json())) ) const validated = R.pipe( toResults(results), R.map((r) => flatMap(r, (data) => validateSchema(userSchema, data))) ) ``` ## Values ### TimeoutError Error type for timeout failures. ```ts title="Signature" const TimeoutError: typeof TimeoutError ``` ## Types ### BatchError Error type for batch processing failures. ```ts title="Signature" interface BatchError { readonly type: 'batch_error' readonly batchIndex: number readonly itemsInBatch: number readonly underlyingError: MapAsyncError | unknown } ``` ### BatchOptions Options for batch operations. ```ts title="Signature" interface BatchOptions { readonly batchSize?: number readonly delayBetweenBatches?: number readonly concurrency?: number } ``` ### ConcurrencyOptions Options for concurrent operations. ```ts title="Signature" interface ConcurrencyOptions { readonly concurrency?: number } ``` ### DebounceOptions Options for debounce operations. ```ts title="Signature" interface DebounceOptions { readonly delay: number readonly leading?: boolean readonly trailing?: boolean } ``` ### FilterAsyncError Error type for filterAsync failures. ```ts title="Signature" interface FilterAsyncError { readonly type: 'filter_async_error' readonly underlyingError: MapAsyncError } ``` ### MapAsyncError Error type for mapAsync failures. ```ts title="Signature" interface MapAsyncError { readonly type: 'map_async_error' readonly index: number readonly item: unknown readonly error: unknown } ``` ### PollError Error type returned when polling fails. ```ts title="Signature" type PollError = | { readonly type: 'max_attempts'; readonly attempts: number } | { readonly type: 'timeout'; readonly elapsed: number } | { readonly type: 'stopped'; readonly attempt: number } ``` ### PollOptions Options for polling operations. ```ts title="Signature" interface PollOptions { readonly interval?: number readonly maxAttempts?: number readonly timeout?: number readonly shouldContinue?: (attempt: number) => boolean readonly onPoll?: (attempt: number) => void } ``` ### RetryError Error type returned when all retry attempts fail. ```ts title="Signature" interface RetryError { readonly type: 'max_attempts_exceeded' readonly lastError: unknown readonly attempts: number } ``` ### RetryOptions Options for retry operations. ```ts title="Signature" interface RetryOptions { readonly maxAttempts?: number readonly delay?: number readonly backoff?: number readonly maxDelay?: number readonly jitter?: number readonly shouldRetry?: (error: unknown, attempt: number) => boolean readonly onRetry?: (error: unknown, attempt: number, delay: number) => void } ``` ### SequentialError Error type for sequential task execution failures. ```ts title="Signature" interface SequentialError { readonly type: 'sequential_error' readonly taskIndex: number readonly completedTasks: number readonly error: unknown } ``` ### ThrottleOptions Options for throttle operations. ```ts title="Signature" interface ThrottleOptions { readonly delay: number readonly leading?: boolean readonly trailing?: boolean } ``` --- # Collection 31 exports from @ontologyio/receta/collection. Everything exported from `@ontologyio/receta/collection`. New here? Start with the [Collection guide](/docs/collection). ## Functions ### batchBy Groups consecutive items in an array based on a predicate or grouping function. Unlike `groupBy` which groups ALL matching items together, `batchBy` only groups consecutive items that produce the same grouping key. Useful for identifying runs, sequences, or consecutive states in time-series data. ```ts title="Data-first" function batchBy(items: readonly T[], fn: (item: T) => K): readonly (readonly T[])[] ``` ```ts title="Data-last" function batchBy(fn: (item: T) => K): (items: readonly T[]) => readonly (readonly T[])[] ``` ```ts title="Example" // Data-first: Group consecutive same values batchBy([1, 1, 2, 2, 2, 3, 1, 1], x => x) // => [[1, 1], [2, 2, 2], [3], [1, 1]] // Group consecutive dates by day const events = [ { timestamp: '2024-01-01T10:00:00Z', event: 'login' }, { timestamp: '2024-01-01T11:00:00Z', event: 'click' }, { timestamp: '2024-01-02T09:00:00Z', event: 'login' }, { timestamp: '2024-01-02T10:00:00Z', event: 'logout' }, ] batchBy(events, e => e.timestamp.split('T')[0]) // => [ // [{ timestamp: '2024-01-01T10:00:00Z', ... }, { timestamp: '2024-01-01T11:00:00Z', ... }], // [{ timestamp: '2024-01-02T09:00:00Z', ... }, { timestamp: '2024-01-02T10:00:00Z', ... }] // ] // Group consecutive status changes const tasks = [ { id: 1, status: 'pending' }, { id: 2, status: 'pending' }, { id: 3, status: 'done' }, { id: 4, status: 'done' }, { id: 5, status: 'pending' }, ] batchBy(tasks, t => t.status) // => [ // [{ id: 1, status: 'pending' }, { id: 2, status: 'pending' }], // [{ id: 3, status: 'done' }, { id: 4, status: 'done' }], // [{ id: 5, status: 'pending' }] // ] // Data-last (in pipe) pipe( [1, 1, 2, 2, 2, 3, 1, 1], batchBy(x => x) ) // => [[1, 1], [2, 2, 2], [3], [1, 1]] ``` **See also** `groupBy`, `chunk`. ### cartesianProduct Generates the Cartesian product of multiple arrays. Creates all possible combinations by taking one element from each input array. Useful for generating test matrices, A/B test variants, configuration combinations, or any scenario requiring all possible combinations. Type-safe for 2-5 arrays with specific tuple types, falls back to generic array for more than 5 arrays. ```ts title="Overload 1" function cartesianProduct(arr1: readonly A[], arr2: readonly B[]): readonly [A, B][] ``` ```ts title="Overload 2" function cartesianProduct(arr1: readonly A[], arr2: readonly B[], arr3: readonly C[]): readonly [A, B, C][] ``` ```ts title="Overload 3" function cartesianProduct(arr1: readonly A[], arr2: readonly B[], arr3: readonly C[], arr4: readonly D[]): readonly [A, B, C, D][] ``` ```ts title="Overload 4" function cartesianProduct(arr1: readonly A[], arr2: readonly B[], arr3: readonly C[], arr4: readonly D[], arr5: readonly E[]): readonly [A, B, C, D, E][] ``` ```ts title="Overload 5" function cartesianProduct(...arrays: readonly (readonly T[])[]): readonly T[][] ``` ```ts title="Example" // Two arrays cartesianProduct(['a', 'b'], [1, 2, 3]) // => [['a', 1], ['a', 2], ['a', 3], ['b', 1], ['b', 2], ['b', 3]] // Three arrays (A/B test variants) const sizes = ['S', 'M', 'L'] const colors = ['red', 'blue'] const materials = ['cotton', 'polyester'] cartesianProduct(sizes, colors, materials) // => [ // ['S', 'red', 'cotton'], // ['S', 'red', 'polyester'], // ['S', 'blue', 'cotton'], // ['S', 'blue', 'polyester'], // ['M', 'red', 'cotton'], // ... // ] // Test matrix generation const browsers = ['chrome', 'firefox', 'safari'] const platforms = ['mac', 'windows', 'linux'] const versions = ['v1', 'v2'] cartesianProduct(browsers, platforms, versions) // => [ // ['chrome', 'mac', 'v1'], // ['chrome', 'mac', 'v2'], // ['chrome', 'windows', 'v1'], // ... // ] // Configuration combinations pipe( cartesianProduct(['dev', 'prod'], ['us', 'eu'], ['v1', 'v2']), R.map(([env, region, version]) => ({ env, region, version, url: `https://${env}-${region}.example.com/${version}` })) ) ``` **See also** `zip`. ### diff Compares two collections and returns added, updated, removed, and unchanged items. Useful for syncing data, detecting changes, and implementing optimistic updates. ```ts title="Data-first" function diff(oldItems: readonly T[], newItems: readonly T[], getId: (item: T) => TId, isEqual?: Comparator | undefined): DiffResult ``` ```ts title="Data-last" function diff(newItems: readonly T[], getId: (item: T) => TId, isEqual?: Comparator | undefined): (oldItems: readonly T[]) => DiffResult ``` ```ts title="Example" // Data-first const oldUsers = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ] const newUsers = [ { id: 1, name: 'Alicia' }, // updated { id: 3, name: 'Charlie' } // added ] diff(oldUsers, newUsers, (u) => u.id) // => { // added: [{ id: 3, name: 'Charlie' }], // updated: [{ old: { id: 1, name: 'Alice' }, new: { id: 1, name: 'Alicia' } }], // removed: [{ id: 2, name: 'Bob' }], // unchanged: [] // } // Data-last (in pipe) pipe( oldUsers, diff(newUsers, (u) => u.id) ) ``` **See also** `union`, `intersect`. ### flatten Flattens a tree structure into a flat array with optional depth control and path tracking. Unlike Remeda's `flat()` which flattens nested arrays, this function handles tree structures where each node contains children. Useful for hierarchical data like file systems, org charts, or nested comments. ```ts title="Overload 1" function flatten(items: readonly T[], config: FlattenConfig & { includePath: true; }): readonly FlattenedItem[] ``` ```ts title="Overload 2" function flatten(items: readonly T[], config: FlattenConfig & { includePath?: false | undefined; }): readonly T[] ``` ```ts title="Overload 3" function flatten(items: readonly T[], config: FlattenConfig): readonly T[] | readonly FlattenedItem[] ``` ```ts title="Overload 4" function flatten(config: FlattenConfig & { includePath: true; }): (items: readonly T[]) => readonly FlattenedItem[] ``` ```ts title="Overload 5" function flatten(config: FlattenConfig & { includePath?: false | undefined; }): (items: readonly T[]) => readonly T[] ``` ```ts title="Overload 6" function flatten(config: FlattenConfig): (items: readonly T[]) => readonly T[] | readonly FlattenedItem[] ``` ```ts title="Example" // Data-first: File system tree interface FileNode { name: string children?: FileNode[] } const tree: FileNode[] = [ { name: 'src', children: [ { name: 'index.ts' }, { name: 'utils', children: [{ name: 'helper.ts' }] } ] } ] flatten(tree, { getChildren: (n) => n.children }) // => [ // { name: 'src', children: [...] }, // { name: 'index.ts' }, // { name: 'utils', children: [...] }, // { name: 'helper.ts' } // ] // With path tracking flatten(tree, { getChildren: (n) => n.children, includePath: true }) // => [ // { item: { name: 'src', ... }, path: [], depth: 0 }, // { item: { name: 'index.ts' }, path: [{ name: 'src', ... }], depth: 1 }, // { item: { name: 'utils', ... }, path: [{ name: 'src', ... }], depth: 1 }, // { item: { name: 'helper.ts' }, path: [{ name: 'src', ... }, { name: 'utils', ... }], depth: 2 } // ] // With max depth flatten(tree, { getChildren: (n) => n.children, maxDepth: 1 }) // => [ // { name: 'src', children: [...] }, // { name: 'index.ts' }, // { name: 'utils', children: [...] } // ] // (stops at depth 1, doesn't include helper.ts) // Data-last (in pipe) pipe( tree, flatten({ getChildren: (n) => n.children }) ) ``` **See also** `nest`. ### groupByPath Groups items by a nested object path. Useful for grouping objects by nested properties like `user.role` or `metadata.category`. Uses type-safe path access via Option pattern. Items with undefined/null paths are grouped under the 'undefined' key. ```ts title="Data-first" function groupByPath(items: readonly T[], path: string): Record ``` ```ts title="Data-last" function groupByPath(path: string): (items: readonly T[]) => Record ``` ```ts title="Example" // Data-first const users = [ { name: 'Alice', profile: { role: 'admin' } }, { name: 'Bob', profile: { role: 'user' } }, { name: 'Charlie', profile: { role: 'admin' } } ] groupByPath(users, 'profile.role') // => { // admin: [ // { name: 'Alice', profile: { role: 'admin' } }, // { name: 'Charlie', profile: { role: 'admin' } } // ], // user: [{ name: 'Bob', profile: { role: 'user' } }] // } // Data-last (in pipe) pipe( users, groupByPath('profile.role') ) ``` **See also** `nest`. ### indexByUnique Creates an index (map) from an array, ensuring keys are unique. Returns a Result to handle duplicate key errors safely. Useful for normalizing data and creating lookup tables. ```ts title="Data-first" function indexByUnique(items: readonly T[], getKey: (item: T) => TKey, config?: IndexConfig | undefined): Result, DuplicateKeyError> ``` ```ts title="Data-last" function indexByUnique(getKey: (item: T) => TKey, config?: IndexConfig | undefined): (items: readonly T[]) => Result, DuplicateKeyError> ``` **Fails with** `DuplicateKeyError`. ```ts title="Example" // Data-first const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ] indexByUnique(users, (u) => u.id) // => Ok({ 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } }) // Duplicate keys const duplicates = [ { id: 1, name: 'Alice' }, { id: 1, name: 'Alicia' } ] indexByUnique(duplicates, (u) => u.id) // => Err(DuplicateKeyError: Duplicate key found: 1) // Handle collisions with config indexByUnique(duplicates, (u) => u.id, { onCollision: 'last' }) // => Ok({ 1: { id: 1, name: 'Alicia' } }) // keeps last indexByUnique(duplicates, (u) => u.id, { onCollision: 'first' }) // => Ok({ 1: { id: 1, name: 'Alice' } }) // keeps first // Data-last (in pipe) pipe( users, indexByUnique((u) => u.id) ) ``` **See also** `nest`. ### insertAt Inserts one or more elements at a specific index (immutably). Creates a new array with elements inserted at the specified index, shifting existing elements as needed. More ergonomic than `splice` for insertion-only operations. Built on top of Remeda's `splice` for consistency. ```ts title="Data-first" function insertAt(items: readonly T[], index: number, values: T | readonly T[]): readonly T[] ``` ```ts title="Data-last" function insertAt(index: number, values: T | readonly T[]): (items: readonly T[]) => readonly T[] ``` ```ts title="Example" // Data-first: Insert single element insertAt(['a', 'b', 'd', 'e'], 2, 'c') // => ['a', 'b', 'c', 'd', 'e'] // Insert multiple elements insertAt([1, 2, 5, 6], 2, [3, 4]) // => [1, 2, 3, 4, 5, 6] // Insert at beginning insertAt(['b', 'c'], 0, 'a') // => ['a', 'b', 'c'] // Insert at end insertAt(['a', 'b'], 2, 'c') // => ['a', 'b', 'c'] // Negative index (from end) insertAt(['a', 'b', 'd'], -1, 'c') // => ['a', 'b', 'c', 'd'] // Add item to todo list at specific position interface Todo { id: number text: string } const todos: Todo[] = [ { id: 1, text: 'Buy milk' }, { id: 3, text: 'Walk dog' }, ] insertAt(todos, 1, { id: 2, text: 'Read book' }) // => [ // { id: 1, text: 'Buy milk' }, // { id: 2, text: 'Read book' }, // { id: 3, text: 'Walk dog' }, // ] // Data-last (in pipe) pipe( ['a', 'b', 'd', 'e'], insertAt(2, 'c') ) // => ['a', 'b', 'c', 'd', 'e'] ``` **See also** `updateAt`, `removeAtIndex`, `moveIndex`. ### intersect Finds elements that exist in both collections using a custom comparator. Similar to Set intersection but works with objects and custom equality. ```ts title="Data-first" function intersect(items1: readonly T[], items2: readonly T[], isEqual?: Comparator | undefined): T[] ``` ```ts title="Data-last" function intersect(items2: readonly T[], isEqual?: Comparator | undefined): (items1: readonly T[]) => T[] ``` ```ts title="Example" // Data-first - with primitive values intersect([1, 2, 3], [2, 3, 4]) // => [2, 3] // With objects and custom comparator const assigned = [{ taskId: 1 }, { taskId: 2 }] const completed = [{ taskId: 2 }, { taskId: 3 }] intersect(assigned, completed, (a, b) => a.taskId === b.taskId) // => [{ taskId: 2 }] // tasks that are both assigned and completed // Data-last (in pipe) pipe( assigned, intersect(completed, (a, b) => a.taskId === b.taskId) ) ``` **See also** `union`, `diff`. ### moveIndex Moves an element from one index to another (immutably). Creates a new array with the element at `fromIndex` moved to `toIndex`, shifting other elements as needed. Useful for drag-and-drop reordering, priority adjustments, or any scenario requiring index-based repositioning. ```ts title="Data-first" function moveIndex(items: readonly T[], fromIndex: number, toIndex: number): readonly T[] ``` ```ts title="Data-last" function moveIndex(fromIndex: number, toIndex: number): (items: readonly T[]) => readonly T[] ``` ```ts title="Example" // Data-first: Move element forward moveIndex(['a', 'b', 'c', 'd', 'e'], 1, 3) // => ['a', 'c', 'd', 'b', 'e'] // Move element backward moveIndex(['a', 'b', 'c', 'd', 'e'], 3, 1) // => ['a', 'd', 'b', 'c', 'e'] // Drag-and-drop reordering interface Task { id: number title: string priority: number } const tasks: Task[] = [ { id: 1, title: 'Task 1', priority: 1 }, { id: 2, title: 'Task 2', priority: 2 }, { id: 3, title: 'Task 3', priority: 3 }, ] // Move task from position 0 to position 2 moveIndex(tasks, 0, 2) // => [ // { id: 2, title: 'Task 2', priority: 2 }, // { id: 3, title: 'Task 3', priority: 3 }, // { id: 1, title: 'Task 1', priority: 1 }, // ] // No-op if indices are same moveIndex([1, 2, 3], 1, 1) // => [1, 2, 3] // Negative indices (from end) moveIndex(['a', 'b', 'c', 'd'], -1, 0) // => ['d', 'a', 'b', 'c'] // Data-last (in pipe) pipe( ['a', 'b', 'c', 'd', 'e'], moveIndex(1, 3) ) // => ['a', 'c', 'd', 'b', 'e'] ``` **See also** `insertAt`, `removeAtIndex`, `updateAt`. ### nest Groups items hierarchically by multiple keys, creating a nested structure. Useful for organizing data like comments grouped by user and post, or products grouped by category and subcategory. ```ts title="Data-first" function nest(items: readonly T[], keys: readonly (keyof T | ((item: T) => string | number))[]): NestedMap ``` ```ts title="Data-last" function nest(keys: readonly (keyof T | ((item: T) => string | number))[]): (items: readonly T[]) => NestedMap ``` ```ts title="Example" // Data-first const comments = [ { userId: 1, postId: 10, text: 'Hello' }, { userId: 1, postId: 10, text: 'World' }, { userId: 2, postId: 20, text: 'Foo' } ] nest(comments, ['userId', 'postId']) // => { // 1: { // 10: [{ userId: 1, postId: 10, text: 'Hello' }, { userId: 1, postId: 10, text: 'World' }] // }, // 2: { // 20: [{ userId: 2, postId: 20, text: 'Foo' }] // } // } // Data-last (in pipe) pipe( comments, nest(['userId', 'postId']) ) ``` **See also** `groupByPath`. ### paginate Paginates an array using offset-based pagination. Useful for implementing API pagination, infinite scroll, and data tables. ```ts title="Data-first" function paginate(items: readonly T[], config: PaginationConfig): PaginatedResult ``` ```ts title="Data-last" function paginate(config: PaginationConfig): (items: readonly T[]) => PaginatedResult ``` ```ts title="Example" // Data-first const users = Array.from({ length: 100 }, (_, i) => ({ id: i })) paginate(users, { page: 1, pageSize: 20 }) // => { // items: [first 20 users], // page: 1, // pageSize: 20, // total: 100, // hasNext: true, // hasPrevious: false // } // Data-last (in pipe) pipe( users, paginate({ page: 2, pageSize: 20 }) ) ``` **See also** `paginateCursor`. ### paginateCursor Paginates an array using cursor-based pagination. More efficient than offset pagination for large datasets and real-time data. Prevents issues with missing/duplicate items during pagination. ```ts title="Data-first" function paginateCursor(items: readonly T[], getCursor: (item: T) => TCursor, config: CursorPaginationConfig): CursorPaginatedResult ``` ```ts title="Data-last" function paginateCursor(getCursor: (item: T) => TCursor, config: CursorPaginationConfig): (items: readonly T[]) => CursorPaginatedResult ``` ```ts title="Example" // Data-first const messages = Array.from({ length: 25 }, (_, i) => ({ id: `msg_${i + 1}` })) paginateCursor(messages, (m) => m.id, { limit: 10 }) // => { // items: [first 10 messages], // nextCursor: 'msg_10', // hasMore: true // } // Continue from cursor paginateCursor(messages, (m) => m.id, { cursor: 'msg_10', limit: 10 }) // Data-last (in pipe) pipe( messages, paginateCursor((m) => m.id, { limit: 10 }) ) ``` **See also** `paginate`. ### removeAtIndex Removes an element at a specific index (immutably). Creates a new array with the element at the specified index removed. More ergonomic than `splice` for single-element removal. Built on top of Remeda's `splice` for consistency. ```ts title="Data-first" function removeAtIndex(items: readonly T[], index: number): readonly T[] ``` ```ts title="Data-last" function removeAtIndex(index: number): (items: readonly T[]) => readonly T[] ``` ```ts title="Example" // Data-first: Remove element removeAtIndex(['a', 'b', 'c', 'd'], 2) // => ['a', 'b', 'd'] // Remove at beginning removeAtIndex([1, 2, 3], 0) // => [2, 3] // Remove at end removeAtIndex([1, 2, 3], 2) // => [1, 2] // Negative index (from end) removeAtIndex(['a', 'b', 'c', 'd'], -1) // => ['a', 'b', 'c'] // Out of bounds returns original array removeAtIndex([1, 2, 3], 10) // => [1, 2, 3] // Remove todo item interface Todo { id: number text: string } const todos: Todo[] = [ { id: 1, text: 'Buy milk' }, { id: 2, text: 'Read book' }, { id: 3, text: 'Walk dog' }, ] removeAtIndex(todos, 1) // => [ // { id: 1, text: 'Buy milk' }, // { id: 3, text: 'Walk dog' }, // ] // Data-last (in pipe) pipe( ['a', 'b', 'c', 'd'], removeAtIndex(2) ) // => ['a', 'b', 'd'] // Chain multiple removals pipe( [1, 2, 3, 4, 5], removeAtIndex(0), // [2, 3, 4, 5] removeAtIndex(-1) // [2, 3, 4] ) // => [2, 3, 4] ``` **See also** `insertAt`, `updateAt`, `moveIndex`. ### symmetricDiff Finds elements that exist in either collection but not both (symmetric difference). Similar to XOR operation on sets. ```ts title="Data-first" function symmetricDiff(items1: readonly T[], items2: readonly T[], isEqual?: Comparator | undefined): T[] ``` ```ts title="Data-last" function symmetricDiff(items2: readonly T[], isEqual?: Comparator | undefined): (items1: readonly T[]) => T[] ``` ```ts title="Example" // Data-first - with primitive values symmetricDiff([1, 2, 3], [2, 3, 4]) // => [1, 4] // 1 only in first, 4 only in second // With objects const planned = [{ id: 1 }, { id: 2 }] const actual = [{ id: 2 }, { id: 3 }] symmetricDiff(planned, actual, (a, b) => a.id === b.id) // => [{ id: 1 }, { id: 3 }] // id 1 not executed, id 3 not planned // Data-last (in pipe) pipe( planned, symmetricDiff(actual, (a, b) => a.id === b.id) ) ``` **See also** `diff`, `union`. ### union Merges two collections, removing duplicates using a custom comparator. Similar to Set union operation but works with objects and custom equality. ```ts title="Data-first" function union(items1: readonly T[], items2: readonly T[], isEqual?: Comparator | undefined): T[] ``` ```ts title="Data-last" function union(items2: readonly T[], isEqual?: Comparator | undefined): (items1: readonly T[]) => T[] ``` ```ts title="Example" // Data-first - with primitive values union([1, 2, 3], [3, 4, 5]) // => [1, 2, 3, 4, 5] // With objects and custom comparator const users1 = [{ id: 1, name: 'Alice' }] const users2 = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] union(users1, users2, (a, b) => a.id === b.id) // => [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] // Data-last (in pipe) pipe( users1, union(users2, (a, b) => a.id === b.id) ) ``` **See also** `intersect`, `symmetricDiff`. ### updateAt Updates (replaces) an element at a specific index (immutably). Creates a new array with the element at the specified index replaced with a new value. More ergonomic than `splice` for single-element replacement. Built on top of Remeda's `splice` for consistency. ```ts title="Data-first" function updateAt(items: readonly T[], index: number, value: T): readonly T[] ``` ```ts title="Data-last" function updateAt(index: number, value: T): (items: readonly T[]) => readonly T[] ``` ```ts title="Example" // Data-first: Update element updateAt(['a', 'b', 'c', 'd'], 2, 'X') // => ['a', 'b', 'X', 'd'] // Update at beginning updateAt([1, 2, 3], 0, 10) // => [10, 2, 3] // Update at end updateAt([1, 2, 3], 2, 30) // => [1, 2, 30] // Negative index (from end) updateAt(['a', 'b', 'c', 'd'], -1, 'Z') // => ['a', 'b', 'c', 'Z'] // Out of bounds returns original array updateAt([1, 2, 3], 10, 99) // => [1, 2, 3] // Update todo item interface Todo { id: number text: string done: boolean } const todos: Todo[] = [ { id: 1, text: 'Buy milk', done: false }, { id: 2, text: 'Read book', done: false }, { id: 3, text: 'Walk dog', done: false }, ] updateAt(todos, 1, { id: 2, text: 'Read book', done: true }) // => [ // { id: 1, text: 'Buy milk', done: false }, // { id: 2, text: 'Read book', done: true }, // { id: 3, text: 'Walk dog', done: false }, // ] // Data-last (in pipe) pipe( ['a', 'b', 'c', 'd'], updateAt(2, 'X') ) // => ['a', 'b', 'X', 'd'] ``` **See also** `insertAt`, `removeAtIndex`, `moveIndex`. ### windowSliding Creates a sliding window over an array with configurable size and step. A sliding window creates overlapping sub-arrays by moving a fixed-size window across the array. Useful for moving averages, n-gram analysis, pattern detection, and time-series analysis. ```ts title="Data-first" function windowSliding(items: readonly T[], config: WindowSlidingConfig): readonly (readonly T[])[] ``` ```ts title="Data-last" function windowSliding(config: WindowSlidingConfig): (items: readonly T[]) => readonly (readonly T[])[] ``` ```ts title="Example" // Data-first: Simple sliding window (step=1) windowSliding([1, 2, 3, 4, 5], { size: 3 }) // => [[1, 2, 3], [2, 3, 4], [3, 4, 5]] // Sliding window with step windowSliding([1, 2, 3, 4, 5, 6], { size: 3, step: 2 }) // => [[1, 2, 3], [3, 4, 5]] // Moving average calculation const prices = [100, 102, 101, 105, 103, 107] pipe( prices, windowSliding({ size: 3 }), R.map(window => window.reduce((a, b) => a + b, 0) / window.length) ) // => [101, 102.66666666666667, 103, 105] // N-gram analysis const words = ['the', 'quick', 'brown', 'fox', 'jumps'] windowSliding(words, { size: 2 }) // => [['the', 'quick'], ['quick', 'brown'], ['brown', 'fox'], ['fox', 'jumps']] // Pattern detection (3-item sequences) const sequence = [1, 2, 3, 2, 3, 4, 3, 4, 5] pipe( sequence, windowSliding({ size: 3 }), R.filter(window => window[0]! < window[1]! && window[1]! < window[2]!) ) // => [[1, 2, 3], [2, 3, 4], [3, 4, 5]] // Data-last (in pipe) pipe( [1, 2, 3, 4, 5], windowSliding({ size: 3 }) ) // => [[1, 2, 3], [2, 3, 4], [3, 4, 5]] ``` **See also** `chunk`, `batchBy`. ## Values ### DuplicateKeyError Error thrown when a duplicate key is found during indexing. ```ts title="Signature" const DuplicateKeyError: typeof DuplicateKeyError ``` ## Types ### Comparator Comparator function for custom equality checks. ```ts title="Signature" type Comparator = (a: T, b: T) => boolean ``` ### CursorPaginatedResult Result of a cursor-based pagination operation. ```ts title="Signature" interface CursorPaginatedResult { readonly items: readonly T[] readonly nextCursor?: TCursor | undefined readonly hasMore: boolean } ``` ```ts title="Example" const page: CursorPaginatedResult = { items: users, nextCursor: 'user_456', hasMore: true } ``` ### CursorPaginationConfig Cursor-based pagination configuration. ```ts title="Signature" interface CursorPaginationConfig { readonly cursor?: TCursor readonly limit: number } ``` ```ts title="Example" const config: CursorPaginationConfig = { cursor: 'user_123', limit: 20 } ``` ### DiffResult Result of a diff operation showing added, updated, and removed items. ```ts title="Signature" interface DiffResult { readonly added: readonly T[] readonly updated: readonly UpdatedItem[] readonly removed: readonly T[] readonly unchanged: readonly T[] } ``` ```ts title="Example" const result: DiffResult = { added: [newUser], updated: [{ old: oldUser, new: updatedUser }], removed: [deletedUser], unchanged: [sameUser] } ``` ### FlattenConfig Configuration for flatten operation. ```ts title="Signature" interface FlattenConfig { readonly getChildren: (node: T) => readonly T[] | undefined readonly maxDepth?: number readonly includePath?: boolean } ``` ```ts title="Example" const config: FlattenConfig = { getChildren: (node) => node.children, maxDepth: 3, includePath: true } ``` ### FlattenedItem Result item from flatten operation with path information. ```ts title="Signature" interface FlattenedItem { readonly item: T readonly path: readonly T[] readonly depth: number } ``` ### IndexConfig Configuration for index operations with collision handling. ```ts title="Signature" interface IndexConfig { readonly onCollision?: 'error' | 'first' | 'last' } ``` ```ts title="Example" const config: IndexConfig = { onCollision: 'error' // or 'first' | 'last' } ``` ### NestConfig Configuration for nested grouping operations. ```ts title="Signature" interface NestConfig { readonly by: readonly (keyof T | ((item: T) => string | number))[] readonly createEmpty?: () => unknown } ``` ```ts title="Example" const config: NestConfig = { by: ['userId', 'postId'], createEmpty: () => [] } ``` ### NestedMap A nested map structure for hierarchical grouping. ```ts title="Signature" type NestedMap = { [key: string | number]: T | NestedMap | readonly T[] } ``` ```ts title="Example" const nested: NestedMap = { 'user_1': { 'post_1': [comment1, comment2], 'post_2': [comment3] } } ``` ### PaginatedResult Result of a pagination operation. ```ts title="Signature" interface PaginatedResult { readonly items: readonly T[] readonly page: number readonly pageSize: number readonly total: number readonly hasNext: boolean readonly hasPrevious: boolean } ``` ```ts title="Example" const page: PaginatedResult = { items: users, page: 1, pageSize: 20, total: 100, hasNext: true, hasPrevious: false } ``` ### PaginationConfig Configuration for pagination operations. ```ts title="Signature" interface PaginationConfig { readonly page: number readonly pageSize: number } ``` ```ts title="Example" const config: PaginationConfig = { page: 1, pageSize: 20 } ``` ### UpdatedItem Represents an item that was updated in a diff operation. ```ts title="Signature" interface UpdatedItem { readonly old: T readonly new: T } ``` ### WindowSlidingConfig Configuration for sliding window operation. ```ts title="Signature" interface WindowSlidingConfig { readonly size: number readonly step?: number } ``` ```ts title="Example" const config: WindowSlidingConfig = { size: 3, step: 1 } ``` --- # Compare 19 exports from @ontologyio/receta/compare. Everything exported from `@ontologyio/receta/compare`. New here? Start with the [Compare guide](/docs/compare). ## Functions ### ascending Creates a comparator that sorts in ascending order based on an extracted value. Works with numbers, strings, dates, and any comparable values. ```ts title="Signature" function ascending(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface User { name: string age: number } const users = [ { name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }, { name: 'Charlie', age: 35 } ] // Sort by age ascending users.sort(ascending(u => u.age)) // => [{ name: 'Bob', age: 25 }, { name: 'Alice', age: 30 }, { name: 'Charlie', age: 35 }] // Sort by name ascending users.sort(ascending(u => u.name)) // => [{ name: 'Alice', ... }, { name: 'Bob', ... }, { name: 'Charlie', ... }] ``` **See also** `descending`, `byKey`. ### byBoolean Creates a comparator for boolean values. By default, false comes before true (false < true). Use `reverse()` for true-first sorting. ```ts title="Signature" function byBoolean(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface Task { title: string completed: boolean urgent: boolean } const tasks = [ { title: 'Review PR', completed: true, urgent: false }, { title: 'Fix bug', completed: false, urgent: true }, { title: 'Write docs', completed: false, urgent: false }, { title: 'Deploy', completed: true, urgent: true } ] // Sort by completed (incomplete first) tasks.sort(byBoolean(t => t.completed)) // => [Fix bug (false), Write docs (false), Review PR (true), Deploy (true)] // Sort by urgent (urgent first) tasks.sort(reverse(byBoolean(t => t.urgent))) // => [Fix bug (true), Deploy (true), Review PR (false), Write docs (false)] // Multi-level: urgent first, then incomplete first tasks.sort( compose( reverse(byBoolean(t => t.urgent)), byBoolean(t => t.completed) ) ) ``` **See also** `reverse`. ### byDate Creates a comparator for date values. Sorts dates chronologically (earliest to latest by default). Handles Date objects correctly for temporal sorting. ```ts title="Signature" function byDate(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface Event { name: string occurredAt: Date scheduledFor: Date } const events = [ { name: 'Meeting', occurredAt: new Date('2024-01-15'), scheduledFor: new Date('2024-02-01') }, { name: 'Launch', occurredAt: new Date('2024-01-10'), scheduledFor: new Date('2024-03-01') }, { name: 'Review', occurredAt: new Date('2024-01-20'), scheduledFor: new Date('2024-02-15') } ] // Sort by occurred date (earliest first) events.sort(byDate(e => e.occurredAt)) // => [Launch (Jan 10), Meeting (Jan 15), Review (Jan 20)] // Sort by scheduled date descending (latest first) events.sort(reverse(byDate(e => e.scheduledFor))) // => [Launch (Mar 1), Review (Feb 15), Meeting (Feb 1)] ``` **See also** `ascending`, `descending`. ### byKey Creates a comparator that sorts by a specific object key. Convenience function for the common case of sorting objects by a property. ```ts title="Signature" function byKey(key: K): Comparator ``` ```ts title="Example" interface Product { id: string name: string price: number stock: number } const products = [ { id: 'c', name: 'Keyboard', price: 80, stock: 5 }, { id: 'a', name: 'Mouse', price: 25, stock: 10 }, { id: 'b', name: 'Monitor', price: 300, stock: 3 } ] // Sort by price products.sort(byKey('price')) // => [{ name: 'Mouse', price: 25, ... }, { name: 'Keyboard', price: 80, ... }, ...] // Sort by name products.sort(byKey('name')) // => [{ name: 'Keyboard', ... }, { name: 'Monitor', ... }, { name: 'Mouse', ... }] // Sort by ID products.sort(byKey('id')) // => [{ id: 'a', ... }, { id: 'b', ... }, { id: 'c', ... }] ``` **See also** `ascending`, `descending`. ### byNumber Creates a comparator for numeric values. Handles all numeric types including integers, floats, and special values (NaN, Infinity). NaN values are treated as equal and sorted to the end. ```ts title="Signature" function byNumber(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface Metric { name: string value: number change: number } const metrics = [ { name: 'CPU', value: 45.2, change: 1.5 }, { name: 'Memory', value: 78.9, change: -2.3 }, { name: 'Disk', value: 23.1, change: 0.8 } ] // Sort by value (lowest first) metrics.sort(byNumber(m => m.value)) // => [Disk (23.1), CPU (45.2), Memory (78.9)] // Sort by change (highest first) metrics.sort(reverse(byNumber(m => m.change))) // => [CPU (+1.5), Disk (+0.8), Memory (-2.3)] ``` **See also** `ascending`, `descending`. ### byString Creates a comparator for string values with optional configuration. Supports case-sensitive/insensitive comparison and locale-aware sorting. ```ts title="Signature" function byString(fn: ComparableExtractor, options?: StringCompareOptions): Comparator ``` ```ts title="Example" interface User { firstName: string lastName: string email: string } const users = [ { firstName: 'alice', lastName: 'Smith', email: 'alice@example.com' }, { firstName: 'Bob', lastName: 'Jones', email: 'bob@example.com' }, { firstName: 'Charlie', lastName: 'Adams', email: 'charlie@example.com' } ] // Case-sensitive sort (default) users.sort(byString(u => u.firstName)) // => [Bob, Charlie, alice] (uppercase comes first) // Case-insensitive sort users.sort(byString(u => u.firstName, { caseSensitive: false })) // => [alice, Bob, Charlie] // Locale-aware sort const germanNames = [ { name: 'MΓΌller' }, { name: 'Mueller' }, { name: 'MΓΆller' } ] germanNames.sort(byString(n => n.name, { locale: 'de-DE' })) ``` **See also** `caseInsensitive`, `localeCompare`. ### caseInsensitive Creates a case-insensitive string comparator. Convenience wrapper for `byString` with `caseSensitive: false`. ```ts title="Signature" function caseInsensitive(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface File { name: string extension: string } const files = [ { name: 'README.md', extension: 'md' }, { name: 'index.ts', extension: 'ts' }, { name: 'App.tsx', extension: 'tsx' }, { name: 'package.json', extension: 'json' } ] // Case-sensitive (default) - uppercase comes first files.sort(byString(f => f.name)) // Order: App.tsx, README.md, index.ts, package.json // Case-insensitive - alphabetical regardless of case files.sort(caseInsensitive(f => f.name)) // Order: App.tsx, index.ts, package.json, README.md ``` **See also** `byString`, `natural`. ### compose Composes multiple comparators into a single comparator with priority ordering. Applies comparators in order: if the first comparator returns 0 (equal), tries the second, and so on. This enables multi-level sorting like "sort by status, then by priority, then by date". ```ts title="Signature" function compose(...comparators: Comparator[]): Comparator ``` ```ts title="Example" interface Issue { status: 'open' | 'closed' priority: 'low' | 'medium' | 'high' createdAt: Date } const issues: Issue[] = [ { status: 'open', priority: 'high', createdAt: new Date('2024-01-03') }, { status: 'open', priority: 'low', createdAt: new Date('2024-01-01') }, { status: 'closed', priority: 'high', createdAt: new Date('2024-01-02') }, { status: 'open', priority: 'high', createdAt: new Date('2024-01-04') } ] // Sort by: status (open first), then priority (high first), then date (oldest first) const priorityOrder = { high: 0, medium: 1, low: 2 } const statusOrder = { open: 0, closed: 1 } issues.sort( compose( ascending(i => statusOrder[i.status]), ascending(i => priorityOrder[i.priority]), ascending(i => i.createdAt) ) ) ``` **See also** `withTiebreaker`. ### descending Creates a comparator that sorts in descending order based on an extracted value. Works with numbers, strings, dates, and any comparable values. ```ts title="Signature" function descending(fn: ComparableExtractor): Comparator ``` ```ts title="Example" interface Transaction { amount: number date: Date } const transactions = [ { amount: 100, date: new Date('2024-01-01') }, { amount: 500, date: new Date('2024-01-03') }, { amount: 200, date: new Date('2024-01-02') } ] // Sort by amount descending (highest first) transactions.sort(descending(t => t.amount)) // => [{ amount: 500, ... }, { amount: 200, ... }, { amount: 100, ... }] // Sort by date descending (most recent first) transactions.sort(descending(t => t.date)) // => [2024-01-03, 2024-01-02, 2024-01-01] ``` **See also** `ascending`, `byKey`. ### localeCompare Creates a locale-aware string comparator. Uses the browser/Node.js locale system for culturally appropriate sorting. Handles accented characters, special characters, and language-specific rules. ```ts title="Signature" function localeCompare(fn: ComparableExtractor, locale: string): Comparator ``` ```ts title="Example" interface City { name: string country: string } const cities = [ { name: 'ZΓΌrich', country: 'Switzerland' }, { name: 'Berlin', country: 'Germany' }, { name: 'MΓΌnchen', country: 'Germany' }, { name: 'Γ…arhus', country: 'Denmark' } ] // German locale (handles umlauts correctly) cities.sort(localeCompare(c => c.name, 'de-DE')) // => [Γ…arhus, Berlin, MΓΌnchen, ZΓΌrich] // French locale const names = [ { name: 'Γ‰tienne' }, { name: 'Eric' }, { name: 'Γ‰mile' } ] names.sort(localeCompare(n => n.name, 'fr-FR')) // => [Γ‰mile, Eric, Γ‰tienne] // English locale const words = [ { word: 'rΓ©sumΓ©' }, { word: 'resume' }, { word: 'result' } ] words.sort(localeCompare(w => w.word, 'en-US')) ``` **See also** `byString`, `caseInsensitive`. ### natural Creates a comparator for natural string sorting. Natural sorting handles numbers within strings intelligently: - "file2.txt" comes before "file10.txt" (not after as with lexicographic sort) - Properly handles mixed alphanumeric strings ```ts title="Signature" function natural(fn: ComparableExtractor): Comparator ``` ```ts title="Example" const files = ['file10.txt', 'file2.txt', 'file1.txt', 'file20.txt'] // Lexicographic sort (incorrect for numbers) files.sort() // => ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'] // Natural sort (correct for numbers) files.sort(natural(x => x)) // => ['file1.txt', 'file2.txt', 'file10.txt', 'file20.txt'] // With objects interface File { name: string size: number } const fileObjects = [ { name: 'image10.png', size: 1024 }, { name: 'image2.png', size: 2048 }, { name: 'image1.png', size: 512 } ] fileObjects.sort(natural(f => f.name)) // => [{ name: 'image1.png', ... }, { name: 'image2.png', ... }, { name: 'image10.png', ... }] ``` **See also** `ascending`, `caseInsensitive`. ### nullsFirst Wraps a comparator to place null items first. It checks the items being compared, not a field inside them. To order objects by a nullable field, give the extractor a fallback that sorts where the nulls should land: `ascending(u => u.lastLogin ?? new Date(0))`. When comparing two null/undefined values, they are considered equal. When comparing null/undefined with a non-null value, null comes first. `Array.prototype.sort` moves `undefined` to the end without calling the comparator, so in practice this reorders `null`. ```ts title="Signature" function nullsFirst(comparator: Comparator>): Comparator> ``` ```ts title="Example" const scores: (number | null)[] = [30, null, 10, null, 20] // The extractor's fallback satisfies the types. A null item never reaches it. scores.sort(nullsFirst(ascending(score => score ?? 0))) // => [null, null, 10, 20, 30] ``` **See also** `nullsLast`. ### nullsLast Wraps a comparator to place null items last. It checks the items being compared, not a field inside them. To order objects by a nullable field, give the extractor a fallback that sorts where the nulls should land: `descending(p => p.discount ?? 0)`. When comparing two null/undefined values, they are considered equal. When comparing null/undefined with a non-null value, null comes last. ```ts title="Signature" function nullsLast(comparator: Comparator>): Comparator> ``` ```ts title="Example" const scores: (number | null)[] = [30, null, 10, null, 20] // The extractor's fallback satisfies the types. A null item never reaches it. scores.sort(nullsLast(ascending(score => score ?? 0))) // => [10, 20, 30, null, null] ``` **See also** `nullsFirst`. ### reverse Reverses the order of a comparator. Useful for inverting existing comparators without rewriting logic. ```ts title="Signature" function reverse(comparator: Comparator): Comparator ``` ```ts title="Example" const users = [ { name: 'Alice', score: 100 }, { name: 'Bob', score: 200 }, { name: 'Charlie', score: 150 } ] // Sort by score ascending const scoreAsc = ascending((u: { score: number }) => u.score) users.sort(scoreAsc) // Scores: 100, 150, 200 // Reverse it for descending users.sort(reverse(scoreAsc)) // Scores: 200, 150, 100 // Equivalent to: users.sort(descending(u => u.score)) ``` **See also** `ascending`, `descending`. ### withTiebreaker Combines a primary comparator with a tiebreaker for when values are equal. More explicit alternative to `compose` for two-level sorting. ```ts title="Signature" function withTiebreaker(primary: Comparator, tiebreaker: Comparator): Comparator ``` ```ts title="Example" interface Player { name: string score: number level: number } const players = [ { name: 'Alice', score: 1000, level: 5 }, { name: 'Bob', score: 1000, level: 3 }, { name: 'Charlie', score: 800, level: 4 } ] // Sort by score (descending), break ties by level (descending) players.sort( withTiebreaker( descending(p => p.score), descending(p => p.level) ) ) // => [{ name: 'Alice', score: 1000, level: 5 }, // { name: 'Bob', score: 1000, level: 3 }, // { name: 'Charlie', score: 800, level: 4 }] ``` **See also** `compose`. ## Types ### ComparableExtractor Helper type for extracting a comparable value from T. ```ts title="Signature" type ComparableExtractor = (value: T) => C ``` ### Comparator A comparator function for sorting values of type T. Returns: - Negative number if a should come before b - Zero if a and b are equal - Positive number if a should come after b ```ts title="Signature" type Comparator = (a: T, b: T) => number ``` ```ts title="Example" const numericComparator: Comparator = (a, b) => a - b const numbers = [3, 1, 2] numbers.sort(numericComparator) // => [1, 2, 3] ``` ### Nullable Represents a value that may be null or undefined. ```ts title="Signature" type Nullable = T | null | undefined ``` ### StringCompareOptions Options for string comparison. ```ts title="Signature" interface StringCompareOptions { readonly caseSensitive?: boolean readonly locale?: string } ``` --- # Function 25 exports from @ontologyio/receta/function. Everything exported from `@ontologyio/receta/function`. New here? Start with the [Function guide](/docs/function). ## Functions ### ap Applies an array of functions to an array of values (applicative apply). Creates all possible combinations by applying each function to each value, returning a flat array of results. This is useful for applying multiple transformations across multiple values, though in practice `juxt` or `converge` are often more intuitive. ```ts title="Data-last" function ap(fns: readonly ((value: T) => U)[]): (values: readonly T[]) => U[] ``` ```ts title="Data-first" function ap(fns: readonly ((value: T) => U)[], values: readonly T[]): U[] ``` ```ts title="Example" const fns = [ (n: number) => n + 1, (n: number) => n * 2, (n: number) => n * n ] ap(fns, [1, 2, 3]) // => [ // 2, 3, 4, // addOne to each // 2, 4, 6, // double each // 1, 4, 9 // square each // ] ``` ```ts title="Example" // Apply multiple validators to multiple inputs const validators = [ (s: string) => s.length > 0, (s: string) => s.length < 100, (s: string) => /^[a-z]+$/i.test(s) ] ap(validators, ['hello', '', 'test123']) // => [ // true, false, true, // length > 0 // true, true, true, // length < 100 // true, false, false // only letters // ] ``` ```ts title="Example" // Data-first const fns = [(n: number) => n + 10, (n: number) => n * 10] const result = ap(fns, [1, 2, 3]) // => [11, 12, 13, 10, 20, 30] ``` ### binary Creates a function that only accepts two arguments, ignoring additional arguments. This is useful when you want to limit the number of arguments passed to a function in callbacks or higher-order functions. ```ts title="Signature" function binary(fn: (a: A, b: B, ...rest: any[]) => R): (a: A, b: B) => R ``` ```ts title="Example" const add = (...nums: number[]) => nums.reduce((a, b) => a + b, 0) add(1, 2, 3, 4) // => 10 // reduce also passes the index and the array, which binary drops const numbers = [1, 2, 3, 4] numbers.reduce(binary(add)) // => 10 ``` ```ts title="Example" // Limiting callback arguments const logTwo = binary(console.log) const letters = ['a', 'b', 'c'] letters.forEach(logTwo) // Logs: // a 0 // b 1 // c 2 // (ignores array parameter) ``` ### compose Composes functions from right to left. Creates a new function that applies the given functions in reverse order, passing the result of each function as input to the next. This is the mathematical composition: (f ∘ g)(x) = f(g(x)) **Note**: For left-to-right composition (more common in data pipelines), use Remeda's `pipe` function instead. ```ts title="Overload 1" function compose(fn1: (a: A) => B): (a: A) => B ``` ```ts title="Overload 2" function compose(fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C ``` ```ts title="Overload 3" function compose(fn3: (c: C) => D, fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => D ``` ```ts title="Overload 4" function compose(fn4: (d: D) => E, fn3: (c: C) => D, fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => E ``` ```ts title="Overload 5" function compose(fn5: (e: E) => F, fn4: (d: D) => E, fn3: (c: C) => D, fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => F ``` ```ts title="Example" const addOne = (n: number) => n + 1 const double = (n: number) => n * 2 const square = (n: number) => n * n const f = compose(square, double, addOne) f(2) // => square(double(addOne(2))) => square(double(3)) => square(6) => 36 ``` ```ts title="Example" // String transformations const exclaim = (s: string) => `${s}!` const toUpper = (s: string) => s.toUpperCase() const trim = (s: string) => s.trim() const shout = compose(exclaim, toUpper, trim) shout(' hello ') // => 'HELLO!' ``` ```ts title="Example" // For left-to-right, use pipe instead import { pipe } from 'remeda' const result = pipe( ' hello ', trim, toUpper, exclaim ) // => 'HELLO!' ``` **See also** `https://remedajs.com/docs#pipe`. ### cond Creates a function that applies the first matching predicate-function pair. Returns a function that tests its input against each predicate in order, applying the corresponding function when a match is found. Returns None if no predicates match. This is like a functional switch statement or if-else chain, useful for multi-way conditional logic with clean, declarative syntax. ```ts title="Data-last" function cond(pairs: readonly CondPair[]): (value: T) => Option ``` ```ts title="Data-first" function cond(pairs: readonly CondPair[], value: T): Option ``` ```ts title="Example" const classifyNumber = cond([ [(n) => n < 0, (n) => 'negative'], [(n) => n === 0, () => 'zero'], [(n) => n > 0 && n < 10, (n) => 'small positive'], [(n) => n >= 10, (n) => 'large positive'] ]) classifyNumber(-5) // => Some('negative') classifyNumber(0) // => Some('zero') classifyNumber(3) // => Some('small positive') classifyNumber(100) // => Some('large positive') ``` ```ts title="Example" // HTTP status code handling const handleStatus = cond([ [(s) => s >= 200 && s < 300, () => 'success'], [(s) => s >= 300 && s < 400, () => 'redirect'], [(s) => s >= 400 && s < 500, () => 'client error'], [(s) => s >= 500, () => 'server error'] ]) handleStatus(200) // => Some('success') handleStatus(404) // => Some('client error') handleStatus(500) // => Some('server error') ``` ```ts title="Example" // Data-first const result = cond([ [(s) => s === 'low', () => 1], [(s) => s === 'medium', () => 5], [(s) => s === 'high', () => 10] ], 'medium') // => Some(5) ``` ### converge Creates a function that applies multiple functions to the same input, then combines their results. The `converge` combinator applies each function in the array to the input value, collecting the results, then passes all results to the `after` function. This is useful when you need to derive multiple values from a single input and combine them in some way. ```ts title="Data-last" function converge(after: (...args: Args) => R, fns: FunctionTuple): (value: T) => R ``` ```ts title="Data-first" function converge(after: (...args: Args) => R, fns: FunctionTuple, value: T): R ``` ```ts title="Example" // Calculate average by dividing sum by length const average = converge( (sum, length) => sum / length, [ (nums) => nums.reduce((a, b) => a + b, 0), // sum (nums) => nums.length // length ] ) average([1, 2, 3, 4, 5]) // => 3 ``` ```ts title="Example" // Build an object from different transformations interface User { name: string; email: string } interface Profile { displayName: string; username: string; domain: string } const buildProfile = converge( (name, username, domain) => ({ displayName: name, username, domain }), [ (user) => user.name, (user) => user.email.split('@')[0]!, (user) => user.email.split('@')[1]! ] ) buildProfile({ name: 'Alice', email: 'alice@example.com' }) // => { displayName: 'Alice', username: 'alice', domain: 'example.com' } ``` ```ts title="Example" // Data-first const result = converge( (min: number, max: number) => max - min, [ (nums: number[]) => Math.min(...nums), (nums: number[]) => Math.max(...nums) ], [1, 5, 3, 9, 2] ) // => 8 (range from 1 to 9) ``` ### flip Creates a function that reverses the order of the first two arguments. This is useful when you have a function with arguments in an inconvenient order for partial application or piping. ```ts title="Overload 1" function flip(fn: (a: A, b: B) => R): (b: B, a: A) => R ``` ```ts title="Overload 2" function flip(fn: (a: A, b: B, c: C) => R): (b: B, a: A, c: C) => R ``` ```ts title="Overload 3" function flip(fn: (a: A, b: B, c: C, d: D) => R): (b: B, a: A, c: C, d: D) => R ``` ```ts title="Example" const divide = (a: number, b: number) => a / b const divideBy = flip(divide) divide(10, 2) // => 5 divideBy(2, 10) // => 5 (same as divide(10, 2)) ``` ```ts title="Example" // Useful for partial application const subtract = (a: number, b: number) => a - b const subtractFrom = flip(subtract) const subtractFrom10 = (n: number) => subtractFrom(n, 10) subtractFrom10(3) // => 7 (10 - 3) ``` ```ts title="Example" // With object methods const concat = (a: string, b: string) => a + b const append = flip(concat) concat('hello', ' world') // => 'hello world' append(' world', 'hello') // => 'hello world' ``` ### guard Validates a value through a series of guard clauses (early return pattern). Tests the input against each predicate in order. If a predicate returns false, immediately returns Err with the corresponding error. If all predicates pass, returns Ok with the original value. This implements the guard clause / early return pattern common in validation chains, allowing you to declaratively express a series of validation rules. ```ts title="Data-last" function guard(pairs: readonly GuardPair[]): (value: T) => Result ``` ```ts title="Data-first" function guard(pairs: readonly GuardPair[], value: T): Result ``` ```ts title="Example" // User validation const validateUser = guard([ [(u) => u.age >= 18, 'Must be 18 or older'], [(u) => u.email.includes('@'), 'Invalid email format'], [(u) => u.name.trim().length > 0, 'Name is required'] ]) validateUser({ age: 25, email: 'test@example.com', name: 'Alice' }) // => Ok({ age: 25, email: 'test@example.com', name: 'Alice' }) validateUser({ age: 16, email: 'test@example.com', name: 'Bob' }) // => Err('Must be 18 or older') validateUser({ age: 25, email: 'invalid', name: 'Charlie' }) // => Err('Invalid email format') ``` ```ts title="Example" // Data-first const result = guard( [ [(n) => n > 0, 'Must be positive'], [(n) => n < 100, 'Must be less than 100'], [(n) => Number.isInteger(n), 'Must be an integer'] ], 42 ) // => Ok(42) ``` ```ts title="Example" import { map } from '@ontologyio/receta/result' // In a pipe for validation chains pipe( parseFormData(), guard([ [(data) => 'username' in data, 'Username is required'], [(data) => data.username.length >= 3, 'Username too short'] ]), map(createUser) ) ``` ```ts title="Example" // With structured errors type ValidationError = { field: string; message: string } const validatePassword = guard([ [ (pwd) => pwd.length >= 8, { field: 'password', message: 'Password must be at least 8 characters' } ], [ (pwd) => /[A-Z]/.test(pwd), { field: 'password', message: 'Password must contain uppercase letter' } ], [ (pwd) => /[0-9]/.test(pwd), { field: 'password', message: 'Password must contain a number' } ] ]) ``` **See also** `when`, `cond`. ### ifElse Creates a function that conditionally applies one of two functions based on a predicate. Returns a function that tests its input against the predicate, then applies either `onTrue` if the predicate passes, or `onFalse` if it fails. ```ts title="Data-last" function ifElse(predicate: Predicate, onTrue: Mapper, onFalse: Mapper): (value: T) => U ``` ```ts title="Data-first" function ifElse(predicate: Predicate, onTrue: Mapper, onFalse: Mapper, value: T): U ``` ```ts title="Example" const classify = ifElse( (n: number) => n >= 0, (n) => 'positive', (n) => 'negative' ) classify(5) // => 'positive' classify(-3) // => 'negative' classify(0) // => 'positive' ``` ```ts title="Example" // Data-first signature const result = ifElse( (age: number) => age >= 18, (age) => ({ status: 'adult', age }), (age) => ({ status: 'minor', age }), 25 ) // => { status: 'adult', age: 25 } ``` ```ts title="Example" // In a pipe pipe( fetchUser(), ifElse( (user) => user.role === 'admin', (user) => grantFullAccess(user), (user) => grantLimitedAccess(user) ) ) ``` ### juxt Creates a function that applies multiple functions to the same input and returns all results as an array. The `juxt` combinator (short for "juxtaposition") applies each function to the input value and collects all results in an array, preserving the order. This is useful when you need to apply multiple transformations to the same value and keep all the results. ```ts title="Data-last" function juxt any)[]>(fns: Fns): (...args: Parameters) => ReturnTypes ``` ```ts title="Data-first" function juxt any)[]>(fns: Fns, ...args: Parameters): ReturnTypes ``` ```ts title="Example" const analyze = juxt([ (nums: number[]) => nums.length, (nums: number[]) => Math.min(...nums), (nums: number[]) => Math.max(...nums), (nums: number[]) => nums.reduce((a, b) => a + b, 0) / nums.length ]) analyze([1, 2, 3, 4, 5]) // => [5, 1, 5, 3] // [length, min, max, average] ``` ```ts title="Example" // Extract multiple fields from an object interface User { id: string; name: string; email: string; role: string } const getUserSummary = juxt([ (user: User) => user.id, (user: User) => user.name, (user: User) => user.role ]) getUserSummary({ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }) // => ['1', 'Alice', 'admin'] ``` ```ts title="Example" // Data-first const result = juxt([ (s: string) => s.toUpperCase(), (s: string) => s.toLowerCase(), (s: string) => s.length ], 'Hello') // => ['HELLO', 'hello', 5] ``` ```ts title="Example" // In a pipe pipe( getUserInput(), juxt([ (input) => input.trim(), (input) => input.length, (input) => input.split(' ').length ]) ) // => [trimmedInput, totalLength, wordCount] ``` ### memoize Creates a memoized version of a function that caches its results. This is a simple re-export of the `memo` module's `memoize` function, provided here for convenience in the function module. By default, uses the first argument as the cache key. For custom cache keys, use `memo.memoizeBy` instead. ```ts title="Signature" function memoize(fn: (...args: Args) => R, options?: MemoizeOptions): MemoizedFunction ``` ```ts title="Example" const fibonacci = memoize((n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) }) fibonacci(40) // Computed once fibonacci(40) // Returned from cache instantly ``` ```ts title="Example" // Memoizing expensive computations const expensiveCalc = memoize((data: string) => { console.log('Computing...') return data.split('').reverse().join('') }) expensiveCalc('hello') // Logs "Computing...", returns "olleh" expensiveCalc('hello') // Returns "olleh" (no log, cached) expensiveCalc('world') // Logs "Computing...", returns "dlrow" ``` ```ts title="Example" // For custom cache keys, use memo.memoizeBy import { memoizeBy } from '@ontologyio/receta/memo' const fetchUser = memoizeBy( (id: string) => fetch(`/api/users/${id}`).then(r => r.json()), (id) => id // Cache key function ) ``` **See also** `memo.memoize`, `memo.memoizeBy`, `memo.memoizeAsync`. ### nAry Creates a function that only accepts N arguments, ignoring additional arguments. This is the generalized version of `unary` and `binary`, allowing you to specify exactly how many arguments should be accepted. ```ts title="Signature" function nAry(n: number, fn: (...args: any[]) => R): (...args: any[]) => R ``` ```ts title="Example" const sum = (...nums: number[]) => nums.reduce((a, b) => a + b, 0) const sumTwo = nAry(2, sum) sumTwo(1, 2, 3, 4, 5) // => 3 (only sums first two) const sumThree = nAry(3, sum) sumThree(1, 2, 3, 4, 5) // => 6 (only sums first three) ``` ```ts title="Example" // Controlling variadic functions const max = (...nums: number[]) => Math.max(...nums) const maxOfTwo = nAry(2, max) maxOfTwo(5, 10, 2, 8) // => 10 (only considers first two) ``` ```ts title="Example" // Creating specialized versions const log = (...args: any[]) => console.log(...args) const logOne = nAry(1, log) const logThree = nAry(3, log) logOne('a', 'b', 'c') // Logs: a logThree('a', 'b', 'c', 'd') // Logs: a b c ``` ### partial Creates a partially applied function by pre-filling arguments from the left. Returns a new function that, when called, invokes the original function with the pre-filled arguments followed by any new arguments. ```ts title="Overload 1" function partial(fn: (...args: Args) => R): (...args: Args) => R ``` ```ts title="Overload 2" function partial(fn: (...args: Args) => R, ...prefilledArgs: any[]): (...args: any[]) => R ``` ```ts title="Example" const greet = (greeting: string, name: string) => `${greeting}, ${name}!` const sayHello = partial(greet, 'Hello') sayHello('Alice') // => 'Hello, Alice!' sayHello('Bob') // => 'Hello, Bob!' ``` ```ts title="Example" // Multiple arguments const multiply = (a: number, b: number, c: number) => a * b * c const double = partial(multiply, 2) double(3, 4) // => 24 (2 * 3 * 4) const quadruple = partial(multiply, 2, 2) quadruple(5) // => 20 (2 * 2 * 5) ``` ```ts title="Example" // Creating specialized functions const log = (level: string, module: string, message: string) => `[${level}] ${module}: ${message}` const logError = partial(log, 'ERROR') const logUserError = partial(log, 'ERROR', 'UserModule') logUserError('Invalid input') // => '[ERROR] UserModule: Invalid input' ``` ### partialRight Creates a partially applied function by pre-filling arguments from the right. Returns a new function that, when called, invokes the original function with any new arguments followed by the pre-filled arguments. This is the mirror of `partial`, filling arguments from the end instead of the start. ```ts title="Overload 1" function partialRight(fn: (...args: Args) => R): (...args: Args) => R ``` ```ts title="Overload 2" function partialRight(fn: (...args: Args) => R, ...prefilledArgs: any[]): (...args: any[]) => R ``` ```ts title="Example" const divide = (a: number, b: number) => a / b const divideBy10 = partialRight(divide, 10) divideBy10(100) // => 10 (100 / 10) divideBy10(50) // => 5 (50 / 10) ``` ```ts title="Example" // Useful when the settings come last const formatDate = (date: Date, format: string, locale: string) => date.toLocaleDateString(locale, { dateStyle: format as any }) const formatInUS = partialRight(formatDate, 'en-US') const formatInFR = partialRight(formatDate, 'fr-FR') const launch = new Date('2026-01-22T12:00:00Z') formatInUS(launch, 'short') // => '1/22/26' formatInFR(launch, 'short') // => '22/01/2026' ``` ```ts title="Example" // Multiple arguments from the right const log = (message: string, level: string, timestamp: string) => `${timestamp} [${level}] ${message}` const logNow = partialRight(log, 'INFO', '2026-01-22T10:00:00Z') logNow('Application started') // => '2026-01-22T10:00:00Z [INFO] Application started' ``` **See also** `partial`. ### switchCase Creates a function that applies the first matching predicate-function pair, with a required default. Similar to `cond`, but returns a value directly (not wrapped in Option) by requiring a default value. This guarantees that a value is always returned, making it useful for exhaustive pattern matching scenarios. The key difference from `cond`: - `cond` returns `Option` (can return None if no match) - `switchCase` returns `U` (always returns a value via default) ```ts title="Data-last" function switchCase(pairs: readonly CondPair[], defaultValue: U): (value: T) => U ``` ```ts title="Data-first" function switchCase(pairs: readonly CondPair[], defaultValue: U, value: T): U ``` ```ts title="Example" // Priority assignment with default const getPriority = switchCase( [ [(s) => s === 'critical', () => 1], [(s) => s === 'high', () => 2], [(s) => s === 'medium', () => 3], [(s) => s === 'low', () => 4] ], 5 // default priority for unknown levels ) getPriority('critical') // => 1 getPriority('high') // => 2 getPriority('unknown') // => 5 (default) ``` ```ts title="Example" // HTTP status handling with default error message const getStatusMessage = switchCase( [ [(s) => s >= 200 && s < 300, () => 'Success'], [(s) => s >= 300 && s < 400, () => 'Redirect'], [(s) => s === 404, () => 'Not Found'], [(s) => s >= 400 && s < 500, () => 'Client Error'], [(s) => s >= 500, () => 'Server Error'] ], 'Unknown Status' ) getStatusMessage(200) // => 'Success' getStatusMessage(404) // => 'Not Found' getStatusMessage(100) // => 'Unknown Status' ``` ```ts title="Example" // Data-first const result = switchCase( [ [(s) => s === 'small', () => 10], [(s) => s === 'medium', () => 20], [(s) => s === 'large', () => 30] ], 15, // default value 'medium' ) // => 20 ``` ```ts title="Example" // In a pipe pipe( getUserRole(), switchCase( [ [(r) => r === 'admin', () => allPermissions], [(r) => r === 'editor', () => editorPermissions], [(r) => r === 'viewer', () => viewPermissions] ], noPermissions // default for unknown roles ) ) ``` ```ts title="Example" // File extension to MIME type mapping const getMimeType = switchCase( [ [(ext) => ext === 'jpg' || ext === 'jpeg', () => 'image/jpeg'], [(ext) => ext === 'png', () => 'image/png'], [(ext) => ext === 'gif', () => 'image/gif'], [(ext) => ext === 'pdf', () => 'application/pdf'], [(ext) => ext === 'json', () => 'application/json'] ], 'application/octet-stream' // default MIME type ) ``` **See also** `cond`, `ifElse`. ### tap Executes a side effect function and returns the original value unchanged. This is invaluable for debugging pipelines or performing side effects (like logging) without breaking the data flow. ```ts title="Data-last" function tap(fn: (value: T) => void): (value: T) => T ``` ```ts title="Data-first" function tap(fn: (value: T) => void, value: T): T ``` ```ts title="Example" // Debugging a pipeline pipe( [1, 2, 3, 4, 5], R.map(x => x * 2), tap(x => console.log('After doubling:', x)), // [2, 4, 6, 8, 10] R.filter(x => x > 5), tap(x => console.log('After filtering:', x)), // [6, 8, 10] R.reduce((a, b) => a + b, 0) ) ``` ```ts title="Example" // Data-first const result = tap( (x) => console.log('Value:', x), 42 ) // Logs: "Value: 42" // => 42 ``` ```ts title="Example" // Side effects in a chain const processUser = pipe( fetchUser(userId), tap(user => analytics.track('user_fetched', { id: user.id })), tap(user => logger.info('Processing user', user)), transformUser, tap(transformed => cache.set(userId, transformed)), saveUser ) ``` ```ts title="Example" // Conditional logging pipe( getData(), tap(data => { if (process.env.NODE_ENV === 'development') { console.log('Debug:', data) } }), processData ) ``` ### tryCatch Wraps a function to safely execute it and return a Result. This is a convenience wrapper around `Result.tryCatch` that creates a function you can reuse, rather than wrapping a single execution. **Note**: This wraps the Result module's `tryCatch`. For one-off executions, use `Result.tryCatch` directly. It only catches synchronous throws. An async function comes back as an `Ok` holding its promise, and a rejection isn't caught. Use `tryCatchAsync` from the Result module for async code. ```ts title="Signature" function tryCatch(fn: (...args: Args) => T, onError?: ((error: unknown) => E) | undefined): (...args: Args) => Result ``` ```ts title="Example" const parseJSON = tryCatch( (str: string) => JSON.parse(str), () => 'Invalid JSON' ) parseJSON('{"valid": "json"}') // => Ok({ valid: 'json' }) parseJSON('invalid json') // => Err('Invalid JSON') ``` ```ts title="Example" // Creating safe versions of throwing functions const safeDivide = tryCatch( (a: number, b: number) => { if (b === 0) throw new Error('Division by zero') return a / b } ) safeDivide(10, 2) // => Ok(5) safeDivide(10, 0) // => Err(Error('Division by zero')) ``` ```ts title="Example" import { flatMap, map } from '@ontologyio/receta/result' // In a pipe with Result operations pipe( getUserInput(), tryCatch((input: string) => JSON.parse(input)), map(data => data.userId), flatMap(fetchUser) ) ``` **See also** `Result.tryCatch`. ### unary Creates a function that only accepts one argument, ignoring additional arguments. This is useful when passing functions to higher-order functions that provide multiple arguments, but you only want to use the first one. ```ts title="Signature" function unary(fn: (a: A, ...rest: any[]) => R): (a: A) => R ``` ```ts title="Example" // parseInt takes two arguments, which causes issues with map const digits = ['1', '2', '3'] digits.map(parseInt) // => [1, NaN, NaN] digits.map(unary(parseInt)) // => [1, 2, 3] ``` ```ts title="Example" const logFirst = unary(console.log) const letters = ['a', 'b', 'c'] letters.forEach(logFirst) // Logs: // a // b // c // (without indices or array) ``` ```ts title="Example" // Wrapping functions for cleaner callbacks const parseNumber = unary(Number) const inputs = ['42', '3.14', '100'] inputs.map(parseNumber) // => [42, 3.14, 100] ``` ### unless Creates a function that conditionally applies a transformation (inverse of when). Returns a function that tests its input against the predicate. If the predicate fails, applies the given function; otherwise returns the input unchanged. This is the inverse of `when` - it applies the transformation when the condition is NOT met. ```ts title="Data-last" function unless(predicate: Predicate, fn: Mapper): (value: T) => T ``` ```ts title="Data-first" function unless(predicate: Predicate, fn: Mapper, value: T): T ``` ```ts title="Example" const ensureArray = unless( Array.isArray, (value) => [value] ) ensureArray([1, 2, 3]) // => [1, 2, 3] ensureArray(5) // => [5] ensureArray('hello') // => ['hello'] ``` ```ts title="Example" // Data-first const result = unless( (str) => str.startsWith('http'), (str) => `https://${str}`, 'example.com' ) // => 'https://example.com' ``` ```ts title="Example" // In a pipe pipe( getConfig(), unless( (config) => 'apiKey' in config, (config) => ({ ...config, apiKey: process.env.API_KEY }) ) ) ``` **See also** `when`. ### when Creates a function that conditionally applies a transformation. Returns a function that tests its input against the predicate. If the predicate passes, applies the given function; otherwise returns the input unchanged. This is useful for conditional transformations where you want to preserve the original value when the condition is not met. ```ts title="Data-last" function when(predicate: Predicate, fn: Mapper): (value: T) => T ``` ```ts title="Data-first" function when(predicate: Predicate, fn: Mapper, value: T): T ``` ```ts title="Example" const ensurePositive = when( (n: number) => n < 0, (n) => Math.abs(n) ) ensurePositive(-5) // => 5 ensurePositive(3) // => 3 ensurePositive(0) // => 0 ``` ```ts title="Example" // Data-first const result = when( (str: string) => str.length === 0, () => 'default', '' ) // => 'default' ``` ```ts title="Example" // In a pipe for conditional transformations pipe( getUserInput(), when( (input) => input.trim().length === 0, () => 'Anonymous' ), processUsername ) ``` **See also** `unless`. ## Types ### CondPair A pair of predicate and corresponding function for conditional execution. ```ts title="Signature" type CondPair = readonly [predicate: Predicate, fn: Mapper] ``` ### FunctionTuple Represents a tuple of functions that take the same input type. ```ts title="Signature" type FunctionTuple = { readonly [K in keyof Args]: Mapper } ``` ### GuardPair A pair of predicate and corresponding error for guard validation. ```ts title="Signature" type GuardPair = readonly [predicate: Predicate, error: E] ``` ### Mapper A function that transforms a value of type T to type U. ```ts title="Signature" type Mapper = (value: T) => U ``` ### Predicate A function that takes a value and returns a boolean. ```ts title="Signature" type Predicate = (value: T) => boolean ``` ### ReturnTypes Extracts the return types of a tuple of functions. ```ts title="Signature" type ReturnTypes any)[]> = { readonly [K in keyof Fns]: Fns[K] extends (...args: any[]) => infer R ? R : never } ``` --- # Lens 12 exports from @ontologyio/receta/lens. Everything exported from `@ontologyio/receta/lens`. New here? Start with the [Lens guide](/docs/lens). ## Functions ### compose Composes two lenses to create a lens that focuses deeper into a structure. Lens composition follows the mathematical composition pattern: `compose(outer, inner)` creates a lens that first applies `outer`, then `inner`. This allows you to build complex lenses from simple ones. ```ts title="Signature" function compose(outer: Lens, inner: Lens): Lens ``` ```ts title="Example" interface User { name: string address: Address } interface Address { street: string city: string } const addressLens = prop('address') const cityLens = prop('city') const userCityLens = compose(addressLens, cityLens) const user = { name: 'Alice', address: { street: '123 Main', city: 'Boston' } } view(userCityLens, user) // 'Boston' set(userCityLens, 'NYC', user) // => { name: 'Alice', address: { street: '123 Main', city: 'NYC' } } ``` ```ts title="Example" // Multiple composition interface State { ui: { modal: { title: string } } } const uiLens = prop('ui') const modalLens = prop('modal') const titleLens = prop('title') const modalTitleLens = compose( compose(uiLens, modalLens), titleLens ) ``` ```ts title="Example" // With array index: index works on readonly arrays and its focus may be undefined interface Todo { text: string done: boolean } interface TodoList { todos: readonly Todo[] } const firstTodoLens = compose(prop('todos'), index(0)) const list: TodoList = { todos: [{ text: 'Buy milk', done: false }] } over(firstTodoLens, todo => todo && { ...todo, done: true }, list) // => { todos: [{ text: 'Buy milk', done: true }] } ``` **See also** `prop`, `path`. ### index Creates a Lens focusing on a specific index in an array. Allows immutable updates to array elements at a specific position. If the index is out of bounds, `get` returns undefined and `set` has no effect. Setting `undefined` has no effect either, so this lens can't remove an element. ```ts title="Signature" function index(idx: number): Lens ``` ```ts title="Example" const firstLens = index(0) const thirdLens = index(2) const numbers = [1, 2, 3, 4, 5] firstLens.get(numbers) // 1 thirdLens.get(numbers) // 3 firstLens.set(10)(numbers) // [10, 2, 3, 4, 5] thirdLens.set(30)(numbers) // [1, 2, 30, 4, 5] ``` ```ts title="Example" // With objects in arrays interface Todo { id: number text: string done: boolean } const todos: Todo[] = [ { id: 1, text: 'Buy milk', done: false }, { id: 2, text: 'Walk dog', done: true } ] const secondTodoLens = index(1) secondTodoLens.get(todos) // { id: 2, text: 'Walk dog', done: true } ``` **See also** `prop`, `path`. ### lens Creates a Lens from a getter and setter function. A lens is a composable abstraction for focusing on a specific part of a data structure. It consists of: - `get`: Extracts the focused value from the source - `set`: Returns a new source with the focused value updated ```ts title="Signature" function lens(get: (source: S) => A, set: (value: A) => (source: S) => S): Lens ``` ```ts title="Example" interface User { name: string age: number } const nameLens = lens( (user) => user.name, (name) => (user) => ({ ...user, name }) ) const user = { name: 'Alice', age: 30 } const newName = nameLens.get(user) // 'Alice' const updated = nameLens.set('Bob')(user) // { name: 'Bob', age: 30 } ``` **See also** `prop`, `path`. ### optional Creates a lens that handles potentially undefined values using Option. This is useful when you have nullable fields and want to work with them safely using the Option pattern. The lens will return Some(value) if the value exists, or None if it's undefined/null. ```ts title="Signature" function optional(baseLens: Lens): Lens> ``` ```ts title="Example" interface User { name: string email?: string phone?: string } const emailLens = prop('email') const optionalEmailLens = optional(emailLens) const user1 = { name: 'Alice', email: 'alice@example.com' } const user2 = { name: 'Bob' } view(optionalEmailLens, user1) // Some('alice@example.com') view(optionalEmailLens, user2) // None ``` ```ts title="Example" // Setting through optional lens import { some, none } from '@ontologyio/receta/option' const user = { name: 'Alice' } set(optionalEmailLens, some('alice@example.com'), user) // => { name: 'Alice', email: 'alice@example.com' } set(optionalEmailLens, none(), user) // => { name: 'Alice', email: undefined } ``` ```ts title="Example" // Transforming optional values import { map } from '@ontologyio/receta/option' over( optionalEmailLens, emailOpt => map(emailOpt, email => email.toLowerCase()), user ) ``` **See also** `prop`, `path`. ### over Applies a transformation function to the value focused by a Lens. This is the most powerful lens operation. It reads the current value, applies a transformation, and sets the result - all immutably. Supports both data-first and data-last (for use in pipes). ```ts title="Data-first" function over(l: Lens, fn: (a: A) => A, source: S): S ``` ```ts title="Data-last" function over(l: Lens, fn: (a: A) => A): (source: S) => S ``` ```ts title="Example" // Data-first const ageLens = prop('age') const user = { name: 'Alice', age: 30 } over(ageLens, age => age + 1, user) // => { name: 'Alice', age: 31 } ``` ```ts title="Example" // Data-last (in pipe) import * as R from 'remeda' const updated = R.pipe( user, over(prop('age'), age => age + 1) ) // { name: 'Alice', age: 31 } ``` ```ts title="Example" // String manipulation const nameLens = prop('name') const user = { name: 'alice', age: 30 } over(nameLens, name => name.toUpperCase(), user) // => { name: 'ALICE', age: 30 } ``` ```ts title="Example" // Complex transformations with nested lenses interface State { cart: { items: Array<{ id: string; quantity: number }> } } const itemsLens = path('cart.items') over( itemsLens, items => items.map(item => item.id === 'abc' ? { ...item, quantity: item.quantity + 1 } : item ), state ) ``` ```ts title="Example" // Incrementing a counter (React state pattern) const countLens = prop('count') setState(over(countLens, n => n + 1)) ``` **See also** `view`, `set`, `prop`. ### path Creates a Lens focusing on a nested property using dot notation. Allows deep access into nested objects using string paths like 'user.address.city'. The lens handles immutable updates at any depth. Paths step through plain objects and arrays, so `items.0.name` reaches into the first item. The path string isn't checked against `S`, and the type `A` is taken on trust. ```ts title="Signature" function path(pathStr: string): Lens ``` ```ts title="Example" interface User { name: string address: { street: string city: string zip: string } } const cityLens = path('address.city') const user = { name: 'Alice', address: { street: '123 Main St', city: 'Boston', zip: '02101' } } cityLens.get(user) // 'Boston' cityLens.set('NYC')(user) // => { name: 'Alice', address: { street: '123 Main St', city: 'NYC', zip: '02101' } } ``` ```ts title="Example" // Deeply nested access interface State { ui: { modal: { isOpen: boolean data: { title: string } } } } const modalTitleLens = path('ui.modal.data.title') ``` **See also** `prop`, `lens`. ### prop Creates a Lens focusing on a specific property of an object. This is a convenience function for creating lenses that access a single property. It's type-safe and ensures the property exists on the object. ```ts title="Signature" function prop(key: K): Lens ``` ```ts title="Example" interface User { name: string email: string age: number } const nameLens = prop('name') const emailLens = prop('email') const user = { name: 'Alice', email: 'alice@example.com', age: 30 } nameLens.get(user) // 'Alice' nameLens.set('Bob')(user) // { name: 'Bob', email: 'alice@example.com', age: 30 } ``` **See also** `lens`, `path`, `view`, `set`. ### set Sets a new value through a Lens, returning an updated source object. This creates a new object with the focused value updated, without mutating the original. The update is immutable and type-safe. Supports both data-first and data-last (for use in pipes). ```ts title="Data-first" function set(l: Lens, value: A, source: S): S ``` ```ts title="Data-last" function set(l: Lens, value: A): (source: S) => S ``` ```ts title="Example" // Data-first const nameLens = prop('name') const user = { name: 'Alice', age: 30 } set(nameLens, 'Bob', user) // => { name: 'Bob', age: 30 } // Original user is unchanged ``` ```ts title="Example" // Data-last (in pipe) import * as R from 'remeda' const updated = R.pipe( user, set(prop('name'), 'Bob') ) // { name: 'Bob', age: 30 } ``` ```ts title="Example" // With nested lenses const cityLens = path('address.city') const user = { name: 'Alice', address: { street: '123 Main', city: 'Boston', zip: '02101' } } set(cityLens, 'NYC', user) // => { // name: 'Alice', // address: { street: '123 Main', city: 'NYC', zip: '02101' } // } ``` **See also** `view`, `over`, `prop`. ### view Gets the value focused by a Lens from a source object. This is the primary way to read through a lens. It applies the lens's `get` function to extract the focused value. Supports both data-first and data-last (for use in pipes). ```ts title="Data-first" function view(l: Lens, source: S): A ``` ```ts title="Data-last" function view(l: Lens): (source: S) => A ``` ```ts title="Example" // Data-first const nameLens = prop('name') const user = { name: 'Alice', age: 30 } view(nameLens, user) // 'Alice' ``` ```ts title="Example" // Data-last (in pipe) import * as R from 'remeda' const result = R.pipe( user, view(prop('name')) ) // 'Alice' ``` ```ts title="Example" // With nested lenses const cityLens = path('address.city') const user = { name: 'Alice', address: { street: '123 Main', city: 'Boston', zip: '02101' } } view(cityLens, user) // 'Boston' ``` **See also** `set`, `over`, `prop`. ## Types ### Lens A Lens is a composable getter/setter pair for immutable updates. A lens focuses on a specific part of a data structure, allowing you to read and update that part without mutating the original structure. ```ts title="Signature" interface Lens { readonly get: (source: S) => A readonly set: (value: A) => (source: S) => S } ``` ```ts title="Example" // Lens focusing on a user's name const nameLens: Lens = { get: (user) => user.name, set: (name) => (user) => ({ ...user, name }) } ``` ### Path A dot-separated path to a nested property, such as `'address.city'`. This is an alias for `string`: the path isn't checked against `T`. ```ts title="Signature" type Path = string ``` ```ts title="Example" const userPath: Path = 'address.city' ``` ### PathValue Type helper to extract the type at a given path. ```ts title="Signature" type PathValue = P extends keyof T ? T[P] : P extends `${infer K}.${infer Rest}` ? K extends keyof T ? PathValue : never : never ``` --- # Memo 15 exports from @ontologyio/receta/memo. Everything exported from `@ontologyio/receta/memo`. New here? Start with the [Memo guide](/docs/memo). ## Functions ### clearCache Clears the cache of a memoized function. ```ts title="Signature" function clearCache(memoized: MemoizedFunction | MemoizedAsyncFunction): void ``` ```ts title="Example" import { memoize, clearCache } from '@ontologyio/receta/memo' const expensive = memoize((n: number) => fibonacci(n)) expensive(40) // computed expensive(40) // cached clearCache(expensive) expensive(40) // computed again // Alternative: use .clear() method expensive.clear() ``` ### invalidateAll Clears the cache of multiple memoized functions at once. Useful for coordinated invalidation across related caches. ```ts title="Signature" function invalidateAll(...memoized: AnyMemoized[]): void ``` ```ts title="Example" import { memoize, memoizeAsync, invalidateAll } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser) const getUserPosts = memoizeAsync(fetchUserPosts) const getUserComments = memoizeAsync(fetchUserComments) // After user deletion async function deleteUser(id: string) { await db.users.delete(id) // Clear all user-related caches invalidateAll(getUser, getUserPosts, getUserComments) } // Alternative: clear specific entries before clearing all getUser.cache.delete(id) getUserPosts.cache.delete(id) invalidateAll(getUserComments) // Clear everything from this one ``` ### invalidateMany Invalidates multiple cache entries by their keys. Useful when you know specific keys to invalidate after a batch operation. ```ts title="Signature" function invalidateMany(memoized: AnyMemoized, keys: K[]): void ``` ```ts title="Example" import { memoize, invalidateMany } from '@ontologyio/receta/memo' const getUser = memoize(fetchUser) // After batch user update await updateUsers(['1', '2', '3'], newData) invalidateMany(getUser, ['1', '2', '3']) // Next calls will recompute getUser('1') // Fetched again getUser('2') // Fetched again ``` ### invalidateWhere Invalidates cache entries matching a predicate. Useful for conditional invalidation based on key patterns or cached values. Works with the default cache, `ttlCache`, `lruCache` and custom caches that have a `forEach` method. Throws for `weakCache`, because a WeakMap can't be listed. ```ts title="Signature" function invalidateWhere(memoized: AnyMemoized, predicate: (key: K, value?: V | undefined) => boolean): void ``` ```ts title="Example" import { memoizeBy, invalidateWhere } from '@ontologyio/receta/memo' const getUser = memoizeBy( fetchUser, (id, type) => `${type}:${id}` ) // Invalidate all admin users invalidateWhere(getUser, (key) => key.startsWith('admin:')) // Invalidate by value condition invalidateWhere(getUser, (_key, user) => user?.deleted === true) ``` ### lruCache Creates a Least-Recently-Used (LRU) cache with a maximum size. When the cache reaches max size, the least recently accessed entry is evicted. Useful for limiting memory usage while keeping hot data cached. ```ts title="Signature" function lruCache(maxSize: number): Cache ``` ```ts title="Example" import { memoize, lruCache } from '@ontologyio/receta/memo' // Cache up to 100 users const getUser = memoize(fetchUser, { cache: lruCache(100) }) // After 100 different users: getUser('user-001') // cached getUser('user-002') // cached // ... 100 more calls ... getUser('user-101') // causes 'user-001' to be evicted ``` ### memoize Creates a memoized version of a function that caches results based on arguments. By default, uses a simple Map cache with the first argument as the key. For multi-argument functions or complex keys, use `memoizeBy()` instead. ```ts title="Signature" function memoize(fn: (...args: Args) => R, options?: MemoizeOptions): MemoizedFunction ``` ```ts title="Example" // Basic memoization const fibonacci = (n: number): number => { if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) } const memoFib = memoize(fibonacci) memoFib(40) // computed (takes time) memoFib(40) // cached (instant) // With max size const getUser = memoize(fetchUser, { maxSize: 100 }) // With TTL const getWeather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) }) // 5 min ``` **See also** `memoizeBy`, `memoizeAsync`. ### memoizeAsync Creates a memoized async function with request deduplication. Key features: - Caches resolved promises - Prevents duplicate in-flight requests (thundering herd protection) - Failed promises are not cached - Supports TTL and LRU cache strategies ```ts title="Signature" function memoizeAsync(fn: (...args: Args) => Promise, options?: MemoizeOptions & { keyFn?: KeyFn | undefined; }): MemoizedAsyncFunction ``` ```ts title="Example" // Basic async memoization const fetchUser = memoizeAsync(async (id: string) => { const res = await fetch(`/api/users/${id}`) return res.json() }) // Concurrent calls - only 1 fetch happens const [a, b, c] = await Promise.all([ fetchUser('123'), fetchUser('123'), fetchUser('123') ]) // All return same result from single API call // With TTL const getWeather = memoizeAsync( async (city: string) => api.fetchWeather(city), { cache: ttlCache(5 * 60 * 1000) } // 5 min cache ) // Custom key extraction const fetchData = memoizeAsync( async (opts: { id: string; type: string }) => api.fetch(opts), { keyFn: (opts) => `${opts.id}:${opts.type}` } ) ``` **See also** `memoize`, `memoizeBy`. ### memoizeBy Creates a memoized function with custom key extraction. Use this when: - Function takes multiple arguments - Function takes complex objects as arguments - You need custom cache key logic ```ts title="Signature" function memoizeBy(fn: (...args: Args) => R, keyFn: KeyFn, options?: MemoizeOptions): MemoizedFunction ``` ```ts title="Example" // Multi-argument function const add = (a: number, b: number) => a + b const memoAdd = memoizeBy(add, (a, b) => `${a},${b}`) memoAdd(2, 3) // computed memoAdd(2, 3) // cached // Complex object arguments interface FetchOptions { id: string include?: string[] } const fetchUser = memoizeBy( (opts: FetchOptions) => api.fetch(opts), (opts) => JSON.stringify(opts) ) // Object key (use with WeakMap cache) const processNode = memoizeBy( (node: Node) => expensiveOperation(node), (node) => node // object as key ) ``` **See also** `memoize`, `memoizeAsync`. ### ttlCache Creates a time-to-live (TTL) cache that automatically expires entries. Entries are removed after the specified TTL duration. Useful for caching data that becomes stale (API responses, weather, prices, etc.). ```ts title="Signature" function ttlCache(ttlMs: number): Cache ``` ```ts title="Example" import { memoize, ttlCache } from '@ontologyio/receta/memo' // Cache weather data for 5 minutes const getWeather = memoize(fetchWeather, { cache: ttlCache(5 * 60 * 1000) }) getWeather('NYC') // fetches from API getWeather('NYC') // cached // ... 5 minutes later ... getWeather('NYC') // fetches from API again ``` ### weakCache Creates a WeakMap-based cache for object keys. Advantages: - Entries are garbage collected when keys are no longer referenced - No memory leaks from unreferenced objects - Perfect for caching computations on DOM nodes, objects, etc. Limitations: - Only works with object keys (not primitives) - Cannot iterate entries - No size limit or TTL support ```ts title="Signature" function weakCache(): Cache ``` ```ts title="Example" import { memoizeBy, weakCache } from '@ontologyio/receta/memo' interface Node { id: string children: Node[] } const processNode = memoizeBy( (node: Node) => expensiveOperation(node), (node) => node, // object as key { cache: weakCache() } ) const node = { id: 'root', children: [] } processNode(node) // computed processNode(node) // cached // When 'node' is GC'd, cache entry is automatically removed ``` ## Types ### Cache Cache interface that memoization functions can use. Allows custom cache implementations (Map, LRU, TTL, WeakMap, etc.). ```ts title="Signature" interface Cache { get(key: K): Option set(key: K, value: V): void has(key: K): boolean delete(key: K): boolean clear(): void forEach?(callback: (value: V, key: K) => void): void } ``` ### KeyFn Function that extracts a cache key from function arguments. ```ts title="Signature" type KeyFn = (...args: Args) => K ``` ### MemoizedAsyncFunction A memoized async function with cache access and deduplication. ```ts title="Signature" interface MemoizedAsyncFunction { (...args: Args): Promise cache: Cache> clear: () => void } ``` ### MemoizedFunction A memoized function with cache access. ```ts title="Signature" interface MemoizedFunction { (...args: Args): R cache: Cache clear: () => void } ``` ### MemoizeOptions Options for memoization functions. ```ts title="Signature" interface MemoizeOptions { cache?: Cache maxSize?: number ttl?: number } ``` --- # Number 34 exports from @ontologyio/receta/number. Everything exported from `@ontologyio/receta/number`. ## Functions ### average Calculates the average (mean) of an array of numbers. Returns None for an empty array to avoid division by zero. ```ts title="Signature" function average(values: readonly number[]): Option ``` ```ts title="Example" average([1, 2, 3, 4]) // => Some(2.5) average([10]) // => Some(10) average([]) // => None // With Option handling pipe( average(scores), unwrapOr(0) ) // Real-world: Rating calculation const calculateRating = (ratings: number[]) => pipe( average(ratings), map(rating => rating.toFixed(1)) ) ``` **See also** `sum`. ### clamp Constrains a number to be within a specified range. If the value is less than min, returns min. If the value is greater than max, returns max. Otherwise, returns the value unchanged. ```ts title="Data-first" function clamp(value: number, min: number, max: number): number ``` ```ts title="Data-last" function clamp(min: number, max: number): (value: number) => number ``` ```ts title="Example" // Data-first clamp(50, 0, 100) // => 50 clamp(150, 0, 100) // => 100 clamp(-10, 0, 100) // => 0 // Data-last (in pipe) pipe( userInput, clamp(0, 100) ) // => constrained to 0-100 // Real-world: Volume control const setVolume = (level: number) => pipe(level, clamp(0, 100)) ``` **See also** `inRange`. ### format Formats a number with specified decimal places and grouping. Uses the Intl.NumberFormat API for locale-aware formatting. ```ts title="Data-first" function format(value: number, options?: FormatOptions | undefined): string ``` ```ts title="Data-last" function format(options?: FormatOptions | undefined): (value: number) => string ``` ```ts title="Example" // Data-first format(1234.5678) // => "1,234.57" format(1234.5678, { decimals: 0 }) // => "1,235" format(1234.5678, { useGrouping: false }) // => "1234.57" format(1234.5678, { locale: 'de-DE' }) // => "1.234,57" // Data-last (in pipe) pipe( price, format({ decimals: 2 }) ) // => formatted price ``` **See also** `toCurrency`, `toPercent`. ### fromBytes Parses a byte size string into a number of bytes. Handles common formats: "1KB", "1.5 MB", "2GB". Supports both binary (1024) and decimal (1000) bases. Case-insensitive unit parsing. ```ts title="Signature" function fromBytes(input: string, base?: "binary" | "decimal"): Result ``` **Fails with** `ParseError`. ```ts title="Example" fromBytes("1KB") // => Ok(1024) fromBytes("1.5 MB") // => Ok(1572864) fromBytes("2GB") // => Ok(2147483648) fromBytes("100 B") // => Ok(100) fromBytes("1KB", 'decimal') // => Ok(1000) fromBytes("invalid") // => Err({ code: 'PARSE_ERROR', ... }) // Real-world: Parse file size limit const validateFileSize = (sizeStr: string, maxStr: string) => pipe( collect([fromBytes(sizeStr), fromBytes(maxStr)]), map(([size, max]) => size <= max) ) ``` **See also** `toBytes`. ### fromCurrency Parses a currency string into a number. Strips currency symbols, grouping separators, and whitespace before parsing. Handles common formats: "$1,234.56", "€1.234,56", "Β£1,234.56". ```ts title="Signature" function fromCurrency(input: string): Result ``` **Fails with** `ParseError`. ```ts title="Example" fromCurrency("$1,234.56") // => Ok(1234.56) fromCurrency("€1.234,56") // => Ok(1234.56) (European format) fromCurrency("Β£1,234") // => Ok(1234) fromCurrency("-$50.00") // => Ok(-50) fromCurrency("invalid") // => Err({ code: 'PARSE_ERROR', ... }) // Real-world: Parse Stripe amount string const parseStripeAmount = (amountStr: string) => pipe( fromCurrency(amountStr), map(amount => Math.round(amount * 100)) // Convert to cents ) ``` **See also** `fromString`, `toCurrency`. ### fromString Parses a string into a number. Returns Ok with the parsed number, or Err with ParseError if parsing fails. Handles common number formats including decimals, negatives, and scientific notation. ```ts title="Signature" function fromString(input: string): Result ``` **Fails with** `ParseError`. ```ts title="Example" fromString("123") // => Ok(123) fromString("123.45") // => Ok(123.45) fromString("-42") // => Ok(-42) fromString("1.23e4") // => Ok(12300) fromString("abc") // => Err({ code: 'PARSE_ERROR', ... }) fromString("") // => Err({ code: 'PARSE_ERROR', ... }) // Real-world: Form input validation const validatePrice = (input: string) => pipe( fromString(input), flatMap(price => price > 0 ? ok(price) : err({ code: 'INVALID_PRICE' }) ) ) ``` **See also** `fromCurrency`. ### inRange Checks if a number is within a specified range (inclusive). Returns true if the value is between min and max (inclusive). Can be used as a predicate builder for filtering. ```ts title="Data-first" function inRange(value: number, min: number, max: number): boolean ``` ```ts title="Data-last" function inRange(min: number, max: number): (value: number) => boolean ``` ```ts title="Example" // Data-first inRange(50, 0, 100) // => true inRange(150, 0, 100) // => false inRange(0, 0, 100) // => true inRange(100, 0, 100) // => true // Data-last (as predicate) const ages = [15, 25, 35, 45] R.filter(ages, inRange(18, 65)) // => [25, 35, 45] ``` **See also** `clamp`. ### interpolate Linearly interpolates between two numbers. Given a progress value between 0 and 1, returns the interpolated value between from and to. Useful for animations, transitions, and gradients. ```ts title="Data-first" function interpolate(from: number, to: number, progress: number): number ``` ```ts title="Data-last" function interpolate(from: number, to: number): (progress: number) => number ``` ```ts title="Example" // Data-first interpolate(0, 100, 0.5) // => 50 interpolate(0, 100, 0) // => 0 interpolate(0, 100, 1) // => 100 interpolate(10, 20, 0.25) // => 12.5 // Data-last (in pipe) pipe( progress, interpolate(startValue, endValue) ) // Real-world: Animation easing const animateValue = (start: number, end: number, duration: number) => { const startTime = Date.now() return () => { const elapsed = Date.now() - startTime const progress = Math.min(elapsed / duration, 1) return interpolate(start, end, progress) } } ``` ### isFinite Type guard to check if a value is a finite number. Returns true if the value is a finite number (not Infinity, -Infinity, or NaN). ```ts title="Signature" function isFinite(value: number): boolean ``` ```ts title="Example" isFinite(42) // => true isFinite(42.5) // => true isFinite(Infinity) // => false isFinite(-Infinity) // => false isFinite(NaN) // => false ``` **See also** `isInteger`. ### isInteger Type guard to check if a value is a safe integer. Returns true if the value is an integer within JavaScript's safe integer range (Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER). ```ts title="Signature" function isInteger(value: number): boolean ``` ```ts title="Example" isInteger(42) // => true isInteger(42.5) // => false isInteger(Number.MAX_SAFE_INTEGER) // => true isInteger(Number.MAX_SAFE_INTEGER + 1) // => false ``` **See also** `isFinite`, `inRange`. ### isNegative Type guard to check if a number is negative. Returns true if the value is less than zero. ```ts title="Signature" function isNegative(value: number): boolean ``` ```ts title="Example" isNegative(-5) // => true isNegative(0) // => false isNegative(42) // => false ``` **See also** `isPositive`. ### isPositive Type guard to check if a number is positive. Returns true if the value is greater than zero. ```ts title="Signature" function isPositive(value: number): boolean ``` ```ts title="Example" import { isPositive } from '@ontologyio/receta/number' isPositive(42) // => true isPositive(0) // => false isPositive(-5) // => false ``` **See also** `isNegative`. ### normalize Scales a number from a given range to the 0-1 range. Useful for data visualization, progress bars, animations, and machine learning feature scaling. ```ts title="Data-first" function normalize(value: number, min: number, max: number): number ``` ```ts title="Data-last" function normalize(min: number, max: number): (value: number) => number ``` ```ts title="Example" // Data-first normalize(75, 0, 100) // => 0.75 normalize(50, 0, 100) // => 0.5 normalize(5, 0, 10) // => 0.5 normalize(0, 0, 100) // => 0 normalize(100, 0, 100) // => 1 // Data-last (in pipe) pipe( currentValue, normalize(minValue, maxValue) ) // Real-world: Progress bar const progressPercentage = (current: number, total: number) => pipe(current, normalize(0, total), (n) => n * 100) // Real-world: Feature scaling for ML const scaleFeature = (values: number[]) => { const min = Math.min(...values) const max = Math.max(...values) return R.map(values, normalize(min, max)) } ``` **See also** `interpolate`. ### parseFormattedNumber Parses a formatted number string (with thousands separators and currency symbols) into a number. Handles: - Thousands separators (commas, spaces, periods) - Currency symbols ($ , Β£, €, etc.) - Parentheses for negative numbers (accounting format) - Decimal points - Negative signs Returns Ok with the parsed number, or Err with ParseError if parsing fails. ```ts title="Signature" function parseFormattedNumber(input: string): Result ``` **Fails with** `ParseError`. ```ts title="Example" // Basic formatted numbers parseFormattedNumber("1,234.56") // => Ok(1234.56) parseFormattedNumber("1 234.56") // => Ok(1234.56) parseFormattedNumber("1.234,56") // => Ok(1234.56) - European format // Currency symbols parseFormattedNumber("$1,234.56") // => Ok(1234.56) parseFormattedNumber("Β£1,234.56") // => Ok(1234.56) parseFormattedNumber("€1.234,56") // => Ok(1234.56) parseFormattedNumber("1,234.56 USD") // => Ok(1234.56) // Negative numbers parseFormattedNumber("-1,234.56") // => Ok(-1234.56) parseFormattedNumber("(1,234.56)") // => Ok(-1234.56) - Accounting format // Invalid input parseFormattedNumber("abc") // => Err({ code: 'PARSE_ERROR', ... }) parseFormattedNumber("") // => Err({ code: 'PARSE_ERROR', ... }) // Real-world: Parse user input const validatePrice = (input: string) => pipe( parseFormattedNumber(input), flatMap(price => price > 0 ? ok(price) : err({ code: 'INVALID_PRICE' }) ) ) // Real-world: Parse financial report const parseRevenue = (text: string) => parseFormattedNumber(text) // "$1,234,567.89" => 1234567.89 ``` **See also** `fromString`, `fromCurrency`. ### percentage Calculates what percentage one number is of another. Returns the percentage as a decimal (0.25 = 25%). Returns 0 if the total is 0 to avoid division by zero. ```ts title="Data-first" function percentage(value: number, total: number): number ``` ```ts title="Data-last" function percentage(total: number): (value: number) => number ``` ```ts title="Example" // Data-first percentage(25, 100) // => 0.25 percentage(1, 4) // => 0.25 percentage(10, 0) // => 0 (safe division) // Data-last (in pipe) pipe( completed, percentage(total), toPercent(1) ) // => "25.0%" // Real-world: Progress indicator const progress = (current: number, total: number) => pipe( percentage(current, total), (p) => p * 100, Math.round ) ``` **See also** `toPercent`. ### random Generates a random number within a specified range. Returns a random number between min (inclusive) and max (exclusive). If only one argument is provided, it's treated as max with min defaulting to 0. ```ts title="Overload 1" function random(max: number): number ``` ```ts title="Overload 2" function random(min: number, max: number): number ``` ```ts title="Example" random(10) // => number between 0 and 10 random(5, 10) // => number between 5 and 10 random(0, 1) // => number between 0 and 1 // Real-world: Random delay for retry backoff const jitter = () => random(0, 1000) const backoffWithJitter = (attempt: number) => Math.pow(2, attempt) * 1000 + jitter() ``` **See also** `step`. ### ratio Calculates the ratio of two numbers. Returns the ratio as a decimal (a / b). Returns 0 if b is 0 to avoid division by zero. ```ts title="Data-first" function ratio(a: number, b: number): number ``` ```ts title="Data-last" function ratio(b: number): (a: number) => number ``` ```ts title="Example" // Data-first ratio(4, 2) // => 2 ratio(3, 4) // => 0.75 ratio(10, 0) // => 0 (safe division) // Data-last (in pipe) pipe( width, ratio(height) ) // => aspect ratio // Real-world: Aspect ratio const aspectRatio = (width: number, height: number) => pipe( ratio(width, height), round(2) ) ``` ### round Rounds a number to a specified number of decimal places. Uses proper rounding (half away from zero). ```ts title="Data-first" function round(value: number, decimals?: number | undefined): number ``` ```ts title="Data-last" function round(decimals?: number | undefined): (value: number) => number ``` ```ts title="Example" // Data-first (requires 2 args) round(1.2345, 0) // => 1 round(1.2345, 2) // => 1.23 round(1.2367, 2) // => 1.24 round(1234.56, -2) // => 1200 // Data-last (in pipe) pipe( price, round(2) ) // Real-world: Price rounding const roundPrice = (price: number) => pipe(price, round(2)) ``` **See also** `toPrecision`. ### roundTo Rounds a number to the nearest multiple of a step value. Useful for quantizing values to specific increments like price steps, slider values, or decimal quantization. ```ts title="Data-first" function roundTo(value: number, step: number): number ``` ```ts title="Data-last" function roundTo(step: number): (value: number) => number ``` ```ts title="Example" // Data-first roundTo(4.23, 0.25) // => 4.25 roundTo(4.22, 0.25) // => 4.25 roundTo(4.10, 0.25) // => 4.00 roundTo(127, 5) // => 125 roundTo(128, 5) // => 130 roundTo(1.234, 0.1) // => 1.2000000000000002 // Data-last (in pipe) pipe( price, roundTo(0.25) ) // Real-world: Price quantization const roundPrice = (price: number) => pipe(price, roundTo(0.01)) // Round to nearest cent // Real-world: Slider step values const quantizeSlider = (value: number, step: number) => roundTo(value, step) ``` **See also** `round`. ### step Rounds a number to the nearest step value. Useful for pricing, UI sliders, or any value that should snap to specific increments. ```ts title="Data-first" function step(value: number, stepSize: number): number ``` ```ts title="Data-last" function step(stepSize: number): (value: number) => number ``` ```ts title="Example" // Data-first step(7, 5) // => 5 step(8, 5) // => 10 step(1.23, 0.25) // => 1.25 step(1.22, 0.25) // => 1.25 // Data-last (in pipe) pipe( sliderValue, step(5) ) // Real-world: Price rounding to .99 const roundToNinetyNine = (price: number) => Math.ceil(price) - 0.01 // Step pricing (e.g., $5 increments) const roundToFiveDollars = (price: number) => step(price, 5) ``` **See also** `round`, `clamp`. ### sum Calculates the sum of an array of numbers. Returns 0 for an empty array. ```ts title="Signature" function sum(values: readonly number[]): number ``` ```ts title="Example" sum([1, 2, 3, 4]) // => 10 sum([]) // => 0 sum([-1, 1]) // => 0 // Floating-point rounding still applies sum([0.1, 0.2, 0.3]) // => 0.6000000000000001 // Real-world: Shopping cart total const cartTotal = (items: CartItem[]) => sum(items.map(item => item.price * item.quantity)) ``` **See also** `average`. ### toBytes Formats a number as a byte size string (KB, MB, GB, etc.). Automatically selects the appropriate unit based on the value. Supports both binary (1024) and decimal (1000) bases. ```ts title="Data-first" function toBytes(value: number, options?: ByteOptions | undefined): string ``` ```ts title="Data-last" function toBytes(options?: ByteOptions | undefined): (value: number) => string ``` ```ts title="Example" // Data-first (binary by default) toBytes(1024) // => "1.00 KB" toBytes(1048576) // => "1.00 MB" toBytes(1536, { decimals: 0 }) // => "2 KB" toBytes(1000, { base: 'decimal' }) // => "1.00 KB" // Data-last (in pipe) pipe( fileSize, toBytes({ decimals: 1 }) ) // Real-world: File size display const displayFileSize = (bytes: number) => bytes < 1024 ? `${bytes} B` : toBytes(bytes, { decimals: 1 }) ``` **See also** `fromBytes`. ### toCompact Formats a number in compact notation (K, M, B, T). Uses the Intl.NumberFormat API with compact notation. For example, 1000 becomes "1K", 1000000 becomes "1M". ```ts title="Data-first" function toCompact(value: number, options?: CompactOptions | undefined): string ``` ```ts title="Data-last" function toCompact(options?: CompactOptions | undefined): (value: number) => string ``` ```ts title="Example" // Data-first toCompact(1000) // => "1K" toCompact(1500) // => "1.5K" toCompact(1000000) // => "1M" toCompact(1234567, { digits: 2 }) // => "1.23M" toCompact(1000, { notation: 'long' }) // => "1 thousand" // Data-last (in pipe) pipe( followerCount, toCompact({ digits: 1 }) ) // Real-world: Social media metrics const displayCount = (count: number) => count < 10000 ? count.toString() : toCompact(count) ``` **See also** `format`. ### toCurrency Formats a number as currency with the specified currency code and locale. Uses the Intl.NumberFormat API for locale-aware currency formatting. ```ts title="Data-first" function toCurrency(value: number, options: CurrencyOptions): string ``` ```ts title="Data-last" function toCurrency(options: CurrencyOptions): (value: number) => string ``` ```ts title="Example" // Data-first toCurrency(1234.56, { currency: 'USD' }) // => "$1,234.56" toCurrency(1234.56, { currency: 'EUR', locale: 'de-DE' }) // => "1.234,56\u00a0€" toCurrency(1234.56, { currency: 'GBP' }) // => "Β£1,234.56" toCurrency(1234, { currency: 'JPY' }) // => "Β₯1,234" (no decimals) // Data-last (in pipe) pipe( orderTotal, toCurrency({ currency: 'USD' }) ) // Real-world: Stripe checkout const displayPrice = (amountInCents: number) => pipe( amountInCents, (n) => n / 100, toCurrency({ currency: 'USD' }) ) ``` **See also** `format`. ### toOrdinal Formats a number as an ordinal (1st, 2nd, 3rd, etc.). Adds the appropriate English ordinal suffix to the number. ```ts title="Data-first" function toOrdinal(value: number): string ``` ```ts title="Data-last" function toOrdinal(): (value: number) => string ``` ```ts title="Example" // Data-first toOrdinal(1) // => "1st" toOrdinal(2) // => "2nd" toOrdinal(3) // => "3rd" toOrdinal(4) // => "4th" toOrdinal(11) // => "11th" toOrdinal(21) // => "21st" toOrdinal(42) // => "42nd" // Data-last (in pipe) pipe( position, toOrdinal ) // Real-world: Leaderboard const displayRank = (rank: number) => `You placed ${toOrdinal(rank)} out of ${total}` ``` ### toPercent Formats a number as a percentage. Multiplies the value by 100 and adds a percent sign. For example, 0.25 becomes "25%", 1.5 becomes "150%". ```ts title="Data-first" function toPercent(value: number, decimals?: number | undefined): string ``` ```ts title="Data-last" function toPercent(decimals?: number | undefined): (value: number) => string ``` ```ts title="Example" // Data-first (requires 2 args) toPercent(0.25, 0) // => "25%" toPercent(0.1234, 2) // => "12.34%" toPercent(1.5, 0) // => "150%" toPercent(0.333333, 1) // => "33.3%" // Data-last (in pipe) pipe( conversionRate, toPercent(2) ) // Real-world: Analytics dashboard const successRate = (successful: number, total: number) => pipe( successful / total, toPercent(1) ) ``` **See also** `percentage`. ### toPrecision Formats a number to a specified number of significant digits. Uses toPrecision() to format the number with the specified precision. ```ts title="Data-first" function toPrecision(value: number, digits: number): string ``` ```ts title="Data-last" function toPrecision(digits: number): (value: number) => string ``` ```ts title="Example" // Data-first toPrecision(123.456, 4) // => "123.5" toPrecision(0.0012345, 3) // => "0.00123" toPrecision(1234567, 3) // => "1.23e+6" // Data-last (in pipe) pipe( measurementValue, toPrecision(3) ) ``` **See also** `round`. ## Types ### ByteOptions Options for byte formatting. ```ts title="Signature" interface ByteOptions { readonly decimals?: number readonly base?: 'binary' | 'decimal' } ``` ### ByteUnit Byte unit types. ```ts title="Signature" type ByteUnit = 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' ``` ### CompactOptions Options for compact number formatting (1K, 1M, etc.). ```ts title="Signature" interface CompactOptions { readonly locale?: string readonly digits?: number readonly notation?: 'short' | 'long' } ``` ### CurrencyOptions Options for currency formatting. ```ts title="Signature" interface CurrencyOptions { readonly currency: string readonly locale?: string readonly showSymbol?: boolean readonly decimals?: number } ``` ### FormatOptions Options for formatting numbers. ```ts title="Signature" interface FormatOptions { readonly decimals?: number readonly useGrouping?: boolean readonly locale?: string readonly minimumFractionDigits?: number readonly maximumFractionDigits?: number } ``` ### InvalidOperationError Error thrown when a number operation is invalid. ```ts title="Signature" interface InvalidOperationError { readonly code: 'INVALID_OPERATION' readonly message: string readonly value?: number } ``` ### ParseError Error thrown when number parsing fails. ```ts title="Signature" interface ParseError { readonly code: 'PARSE_ERROR' readonly message: string readonly input: string } ``` --- # Object 31 exports from @ontologyio/receta/object. Everything exported from `@ontologyio/receta/object`. New here? Start with the [Object guide](/docs/object). ## Functions ### compact Removes all nullish (null or undefined) values from an object (shallow). Creates a new object without properties that have null or undefined values. Useful for cleaning data, preparing payloads, and removing empty fields. Note: This is a shallow operation. Nested objects are not compacted. ```ts title="Signature" function compact(obj: T): Partial ``` ```ts title="Example" const input = { name: 'Alice', age: null, email: 'alice@example.com', phone: undefined } compact(input) // => { name: 'Alice', email: 'alice@example.com' } // Cleaning API response pipe( apiResponse, compact, validateShape(userSchema) ) // Note: Keeps falsy values like 0, false, '' compact({ count: 0, active: false, name: '' }) // => { count: 0, active: false, name: '' } ``` **See also** `stripUndefined`. ### deepMerge Deep merges multiple objects with configurable conflict resolution. Recursively merges objects, with later objects taking precedence over earlier ones. Arrays and nested objects can be merged according to specified strategies. ```ts title="Data-first" function deepMerge(objects: readonly PlainObject[], options?: DeepMergeOptions | undefined): PlainObject ``` ```ts title="Data-last" function deepMerge(options?: DeepMergeOptions | undefined): (objects: readonly PlainObject[]) => PlainObject ``` ```ts title="Example" // Basic deep merge const defaults = { theme: 'light', features: { search: true } } const config = { features: { export: true } } deepMerge([defaults, config]) // => { theme: 'light', features: { search: true, export: true } } // Array replace strategy (default) deepMerge([ { tags: ['a', 'b'] }, { tags: ['c'] } ]) // => { tags: ['c'] } // Array concat strategy deepMerge([ { tags: ['a', 'b'] }, { tags: ['c'] } ], { arrayStrategy: 'concat' }) // => { tags: ['a', 'b', 'c'] } // Custom merge function deepMerge([obj1, obj2], { customMerge: (key, target, source) => { if (key === 'count') return (target as number) + (source as number) return source } }) // Data-last (in pipe) pipe( defaults, (defaults) => deepMerge([defaults, userConfig]) ) ``` **See also** `Remeda.merge`, `Remeda.mergeDeep`. ### filterKeys Filters an object by keeping only keys that match a predicate. Creates a new object containing only entries whose keys satisfy the predicate. Useful for filtering by key patterns, removing prefixed keys, or selective property inclusion based on key names. ```ts title="Data-first" function filterKeys(obj: T, predicate: (key: string) => boolean): Partial ``` ```ts title="Data-last" function filterKeys(predicate: (key: string) => boolean): (obj: T) => Partial ``` ```ts title="Example" // Data-first const config = { api_key: 'secret', api_url: 'https://api.example.com', db_host: 'localhost', db_port: 5432 } filterKeys(config, (key) => key.startsWith('api_')) // => { api_key: 'secret', api_url: 'https://api.example.com' } // Remove private keys filterKeys(obj, (key) => !key.startsWith('_')) // Keep only specific patterns filterKeys(data, (key) => /^[a-z]+$/.test(key)) // Data-last (in pipe) pipe( config, filterKeys((key) => !key.includes('secret')) ) ``` **See also** `filterValues`, `mask`. ### filterValues Filters an object by keeping only entries whose values match a predicate. Creates a new object containing only entries whose values satisfy the predicate. Useful for removing empty values, filtering by type, or keeping only specific value ranges. ```ts title="Data-first" function filterValues(obj: T, predicate: (value: T[keyof T], key: keyof T) => boolean): Partial ``` ```ts title="Data-last" function filterValues(predicate: (value: any, key: string) => boolean): (obj: T) => Partial ``` ```ts title="Example" // Data-first const scores = { alice: 85, bob: 60, charlie: 92, diana: 55 } filterValues(scores, (score) => score >= 70) // => { alice: 85, charlie: 92 } // Remove empty strings filterValues({ a: 'hello', b: '', c: 'world' }, (v) => v !== '') // => { a: 'hello', c: 'world' } // Type filtering const mixed = { a: 1, b: 'two', c: 3, d: 'four' } filterValues(mixed, (v) => typeof v === 'number') // => { a: 1, c: 3 } // With key and value filterValues(obj, (value, key) => value > threshold[key]) // Data-last (in pipe) pipe( data, filterValues((v) => v != null) ) ``` **See also** `filterKeys`, `compact`. ### flatten Flattens a nested object into a single-level object with dot-notation keys. Converts nested structures like `{ user: { name: 'Alice' } }` into `{ 'user.name': 'Alice' }`. Useful for form data, query parameters, and database operations. ```ts title="Data-first" function flatten(obj: PlainObject, options?: FlattenOptions | undefined): FlatObject ``` ```ts title="Data-last" function flatten(options?: FlattenOptions | undefined): (obj: PlainObject) => FlatObject ``` ```ts title="Example" // Data-first const nested = { user: { name: 'Alice', age: 30 } } flatten(nested) // => { 'user.name': 'Alice', 'user.age': 30 } // Custom separator flatten(nested, { separator: '_' }) // => { 'user_name': 'Alice', 'user_age': 30 } // Max depth flatten({ a: { b: { c: 1 } } }, { maxDepth: 1 }) // => { 'a.b': { c: 1 } } // flatten() works in a pipe. With options, wrap it, because TypeScript // reads flatten({ separator: '_' }) as the data-first call. pipe( nested, (obj) => flatten(obj, { separator: '_' }) ) ``` **See also** `unflatten`. ### getPath Safely gets a value at a given path in an object, returning Option. Navigates nested object structures using a path (array of keys). Returns None if the path runs through a missing or null value, or ends at undefined. A value of null at the end of the path comes back as Some(null). Unlike optional chaining, the result composes with other Option operations. ```ts title="Data-first" function getPath(path: ObjectPath, obj: PlainObject): Option ``` ```ts title="Data-last" function getPath(path: ObjectPath): (obj: PlainObject) => Option ``` ```ts title="Example" import { map, unwrapOr } from '@ontologyio/receta/option' // Data-first const config = { database: { host: 'localhost', port: 5432 } } getPath(['database', 'host'], config) // => Some('localhost') getPath(['database', 'user'], config) // => None getPath(['api', 'key'], config) // => None // Composing with Option pipe( getPath(['database', 'host'], config), map(host => `postgres://${host}`), unwrapOr('postgres://localhost') ) // Data-last (in pipe) pipe( config, getPath(['database', 'port']) ) ``` **See also** `setPath`, `updatePath`. ### mapKeys Transforms all keys in an object using a mapping function. Creates a new object with all keys transformed by the provided function. Useful for normalizing keys (camelCase, snake_case), prefixing, or other systematic key transformations. Note: If multiple keys map to the same new key, later values overwrite earlier ones. ```ts title="Data-first" function mapKeys(obj: T, fn: (key: string, value: T[keyof T]) => string): PlainObject ``` ```ts title="Data-last" function mapKeys(fn: (key: string, value: any) => string): (obj: T) => PlainObject ``` ```ts title="Example" // Data-first const snakeCase = { first_name: 'Alice', last_name: 'Smith', email_address: 'alice@example.com' } mapKeys(snakeCase, (key) => key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())) // => { firstName: 'Alice', lastName: 'Smith', emailAddress: 'alice@example.com' } // Prefixing keys mapKeys({ id: 1, name: 'Alice' }, (key) => `user_${key}`) // => { user_id: 1, user_name: 'Alice' } // With key and value mapKeys(obj, (key, value) => typeof value === 'number' ? key.toUpperCase() : key) // Data-last (in pipe) pipe( apiResponse, mapKeys((key) => toCamelCase(key)) ) ``` **See also** `rename`, `Remeda.mapKeys`. ### mapValues Transforms all values in an object using a mapping function. Creates a new object with all values transformed by the provided function. This is a re-export of Remeda's mapValues for consistency and to enable integration with Result/Option patterns. ```ts title="Data-first" function mapValues(obj: T, fn: (value: T[keyof T], key: keyof T) => U): Record ``` ```ts title="Data-last" function mapValues(fn: (value: any, key: string) => U): (obj: T) => Record ``` ```ts title="Example" // Data-first const prices = { apple: 1.5, banana: 0.5, orange: 2.0 } mapValues(prices, (price) => price * 1.1) // 10% increase // => { apple: 1.65, banana: 0.55, orange: 2.2 } // Type conversion mapValues({ a: 1, b: 2, c: 3 }, (n) => String(n)) // => { a: '1', b: '2', c: '3' } // With key and value mapValues(obj, (value, key) => `${String(key)}: ${value}`) // Data-last (in pipe) pipe( config, mapValues((v) => normalizeValue(v)) ) ``` **See also** `mapKeys`, `Remeda.mapValues`. ### mask Masks an object by keeping only specified keys (allowlist-based filtering). Security-focused alternative to Remeda's `pick`. Creates a new object containing only the allowed keys. Useful for sanitizing data before sending to clients, logging, or storing. The key difference from `pick` is the semantic intent: `mask` emphasizes security and data protection, making it clearer when filtering sensitive data. ```ts title="Data-first" function mask(obj: T, allowedKeys: readonly string[]): Partial ``` ```ts title="Data-last" function mask(allowedKeys: readonly string[]): (obj: T) => Partial ``` ```ts title="Example" // Data-first const user = { id: 1, email: 'alice@example.com', passwordHash: 'secret', creditCard: '4111-1111-1111-1111' } mask(user, ['id', 'email']) // => { id: 1, email: 'alice@example.com' } // API response sanitization const safeUser = mask(dbUser, ['id', 'name', 'email', 'role']) // Data-last (in pipe) pipe( user, mask(['id', 'email', 'name']) ) ``` **See also** `Remeda.pick`. ### rename Renames keys in an object according to a mapping. Creates a new object with renamed keys. If a key isn't in the mapping, it's kept as-is. Useful for transforming API responses, normalizing data, and adapting to different schemas. ```ts title="Data-first" function rename(obj: T, mapping: Record): PlainObject ``` ```ts title="Data-last" function rename(mapping: Record): (obj: T) => PlainObject ``` ```ts title="Example" // Data-first const user = { firstName: 'Alice', lastName: 'Smith' } rename(user, { firstName: 'given_name', lastName: 'family_name' }) // => { given_name: 'Alice', family_name: 'Smith' } // Partial mapping (unmapped keys preserved) rename({ id: 1, name: 'Alice', age: 30 }, { name: 'fullName' }) // => { id: 1, fullName: 'Alice', age: 30 } // Data-last (in pipe) pipe( apiResponse, rename({ user_id: 'userId', created_at: 'createdAt' }) ) ``` **See also** `mapKeys`. ### setPath Immutably sets a value at a given path in an object. Creates a new object with the value at the specified path updated. Creates intermediate objects/arrays as needed. Returns a shallow copy with only the path modified. ```ts title="Data-first" function setPath(path: ObjectPath, value: unknown, obj: T): T ``` ```ts title="Data-last" function setPath(path: ObjectPath, value: unknown): (obj: T) => T ``` ```ts title="Example" // Data-first const config = { database: { host: 'localhost' } } setPath(['database', 'port'], 5432, config) // => { database: { host: 'localhost', port: 5432 } } // Creating intermediate paths setPath(['api', 'endpoints', 'users'], '/api/v1/users', {}) // => { api: { endpoints: { users: '/api/v1/users' } } } // Array indices setPath(['items', 1], 'updated', { items: ['a', 'b'] }) // => { items: ['a', 'updated'] } // Data-last (in pipe) pipe( config, setPath(['database', 'host'], 'prod.example.com'), setPath(['database', 'port'], 5432) ) ``` **See also** `getPath`, `updatePath`. ### stripEmpty Removes all empty values from an object. By default, removes: - null and undefined - Empty strings ('') - Empty arrays ([]) - Empty objects (\{\}) This is useful for cleaning API payloads, removing optional fields before sending data, and normalizing objects for comparison or storage. ```ts title="Data-first" function stripEmpty(obj: PlainObject, options?: StripEmptyOptions | undefined): PlainObject ``` ```ts title="Data-last" function stripEmpty(options?: StripEmptyOptions | undefined): (obj: PlainObject) => PlainObject ``` ```ts title="Example" // Basic usage const input = { name: 'Alice', email: '', tags: [], metadata: {}, age: null, verified: false } stripEmpty(input) // => { name: 'Alice', verified: false } // Keeps non-empty values (0, false are not empty) stripEmpty({ count: 0, active: false, name: '' }) // => { count: 0, active: false } // Deep cleaning of nested objects const nested = { user: { name: 'Alice', bio: '', settings: { theme: null, notifications: [] } }, metadata: {} } stripEmpty(nested) // => { user: { name: 'Alice' } } // Customize what to strip stripEmpty( { name: '', tags: [], count: 0 }, { stripEmptyStrings: false, stripEmptyArrays: true } ) // => { name: '', count: 0 } // Preparing API request payload const formData = { title: 'New Post', body: 'Content here', tags: [], draft: false, publishedAt: null } const clean = stripEmpty(formData) fetch('/api/posts', { method: 'POST', body: JSON.stringify(clean) }) // Sends: { title: 'New Post', body: 'Content here', draft: false } // Shallow stripping (not recursive) stripEmpty( { name: 'Alice', nested: { bio: '', age: null } }, { deep: false } ) // => { name: 'Alice', nested: { bio: '', age: null } } // stripEmpty() works in a pipe. With options, wrap it, because TypeScript // reads stripEmpty({ deep: false }) as the data-first call. pipe( apiPayload, (payload) => stripEmpty(payload) ) ``` **See also** `compact`, `stripUndefined`. ### stripUndefined Removes all undefined values from an object (shallow). Creates a new object without properties that have undefined values. Useful for cleaning up data before sending to APIs, preventing prototype pollution, and preparing data for serialization. Note: This is a shallow operation. Use with `mapValues` for deep cleaning. ```ts title="Signature" function stripUndefined(obj: T): Partial ``` ```ts title="Example" // Data-first const input = { name: 'Alice', age: undefined, email: 'alice@example.com' } stripUndefined(input) // => { name: 'Alice', email: 'alice@example.com' } // Preparing API payload const payload = stripUndefined({ name: formData.name, email: formData.email, phone: formData.phone // might be undefined }) // Data-last (in pipe) pipe( formData, stripUndefined, validateShape(userSchema) ) ``` **See also** `compact`. ### transformKeys Recursively transforms all keys in an object and its nested structures. This function is useful for converting API responses between different naming conventions (e.g., snake_case from Python APIs to camelCase in JS). Supports deep transformation of nested objects and arrays of objects. ```ts title="Data-first" function transformKeys(obj: PlainObject, transform: KeyTransform, options?: TransformKeysOptions | undefined): PlainObject ``` ```ts title="Data-last" function transformKeys(transform: KeyTransform, options?: TransformKeysOptions | undefined): (obj: PlainObject) => PlainObject ``` ```ts title="Example" // Convert snake_case API response to camelCase const apiResponse = { user_id: 123, user_profile: { first_name: 'Alice', last_name: 'Smith', contact_info: { email_address: 'alice@example.com' } } } transformKeys(apiResponse, 'camelCase') // => { // userId: 123, // userProfile: { // firstName: 'Alice', // lastName: 'Smith', // contactInfo: { // emailAddress: 'alice@example.com' // } // } // } // Convert camelCase to snake_case for API request const jsObject = { userId: 123, userProfile: { firstName: 'Alice', contactInfo: { emailAddress: 'alice@example.com' } } } transformKeys(jsObject, 'snakeCase') // => { // user_id: 123, // user_profile: { // first_name: 'Alice', // contact_info: { // email_address: 'alice@example.com' // } // } // } // Transform arrays of objects const users = { user_list: [ { first_name: 'Alice', user_id: 1 }, { first_name: 'Bob', user_id: 2 } ] } transformKeys(users, 'camelCase') // => { // userList: [ // { firstName: 'Alice', userId: 1 }, // { firstName: 'Bob', userId: 2 } // ] // } // Custom transformation function transformKeys( { hello: 'world', foo: 'bar' }, (key) => key.toUpperCase() ) // => { HELLO: 'world', FOO: 'bar' } // Shallow transformation (not recursive) transformKeys( { user_name: 'Alice', user_meta: { created_at: '2024-01-01' } }, 'camelCase', { deep: false } ) // => { userName: 'Alice', userMeta: { created_at: '2024-01-01' } } // Data-last (in pipe) pipe( fetchUserData(), transformKeys('camelCase') ) ``` **See also** `mapKeys`. ### unflatten Unflattens a single-level object with dot-notation keys into a nested object. Reverses the flattening operation, converting `{ 'user.name': 'Alice' }` back into `{ user: { name: 'Alice' } }`. ```ts title="Data-first" function unflatten(obj: FlatObject, options?: UnflattenOptions | undefined): PlainObject ``` ```ts title="Data-last" function unflatten(options?: UnflattenOptions | undefined): (obj: FlatObject) => PlainObject ``` ```ts title="Example" // Data-first const flat = { 'user.name': 'Alice', 'user.age': 30 } unflatten(flat) // => { user: { name: 'Alice', age: 30 } } // Custom separator unflatten({ 'user_name': 'Alice' }, { separator: '_' }) // => { user: { name: 'Alice' } } // Array indices unflatten({ 'items.0': 'a', 'items.1': 'b' }) // => { items: ['a', 'b'] } // unflatten() works in a pipe. With options, wrap it, because TypeScript // reads unflatten({ separator: '_' }) as the data-first call. pipe( flat, (obj) => unflatten(obj) ) ``` **See also** `flatten`. ### updatePath Immutably updates a value at a given path using a function. Retrieves the current value at the path, applies the update function, and sets the new value. If the path doesn't exist, the object is returned unchanged. ```ts title="Data-first" function updatePath(path: ObjectPath, fn: (value: V) => V, obj: T): T ``` ```ts title="Data-last" function updatePath(path: ObjectPath, fn: (value: V) => V): (obj: T) => T ``` ```ts title="Example" // Data-first const user = { profile: { views: 10 } } updatePath(['profile', 'views'], (n: number) => n + 1, user) // => { profile: { views: 11 } } // Path doesn't exist - returns original updatePath(['profile', 'likes'], (n: number) => n + 1, user) // => { profile: { views: 10 } } // Transforming nested data updatePath(['database', 'host'], (host: string) => host.toUpperCase(), config) // Data-last (in pipe) pipe( user, updatePath(['profile', 'views'], (n: number) => n + 1), updatePath(['profile', 'lastSeen'], () => new Date()) ) ``` **See also** `getPath`, `setPath`. ### validateShape Validates an object against a schema, returning Result. Checks that an object matches a given shape/schema. Returns Ok if valid, Err with details if invalid. Useful for runtime type checking, API validation, and form validation. ```ts title="Data-first" function validateShape(obj: unknown, schema: ObjectSchema): Result ``` ```ts title="Data-last" function validateShape(schema: ObjectSchema): (obj: unknown) => Result ``` **Fails with** `ObjectError`. ```ts title="Example" import { map } from '@ontologyio/receta/result' // Define schema with predicates const userSchema = { id: (v: unknown): v is number => typeof v === 'number', name: (v: unknown): v is string => typeof v === 'string', email: (v: unknown): v is string => typeof v === 'string' && v.includes('@') } // Data-first const input = { id: 1, name: 'Alice', email: 'alice@example.com' } validateShape(input, userSchema) // => Ok({ id: 1, name: 'Alice', email: 'alice@example.com' }) const invalid = { id: '1', name: 'Alice', email: 'invalid' } validateShape(invalid, userSchema) // => Err({ code: 'VALIDATION_ERROR', message: 'Validation failed for key "id"', path: ['id'] }) // Data-last (in pipe) pipe( apiResponse, stripUndefined, (obj) => validateShape(obj, userSchema), map(user => processUser(user)) ) // With Predicate module import { isString, isNumber } from '@ontologyio/receta/predicate' const profileSchema = { id: isNumber, name: isString, age: (v: unknown): v is number => isNumber(v) && v >= 0 && v <= 150 } ``` **See also** `Validation`. ## Types ### DeepMergeOptions Options for deep merging objects. ```ts title="Signature" interface DeepMergeOptions { readonly arrayStrategy?: 'replace' | 'concat' | 'merge' readonly customMerge?: (key: string, target: unknown, source: unknown) => unknown } ``` ### DeepPartial Makes all properties of T deeply partial (optional). ```ts title="Signature" type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] } ``` ### FlatObject Flattened object with dot-notation keys. ```ts title="Signature" type FlatObject = Record ``` ```ts title="Example" const flat: FlatObject = { 'user.name': 'Alice', 'user.age': 30 } ``` ### FlattenOptions Options for flattening objects. ```ts title="Signature" interface FlattenOptions { readonly separator?: string readonly maxDepth?: number readonly flattenArrays?: boolean } ``` ### KeysOfType Keys of T that have values of type V. ```ts title="Signature" type KeysOfType = { [K in keyof T]: T[K] extends V ? K : never }[keyof T] ``` ### KeyTransform Type of key transformation to apply. ```ts title="Signature" type KeyTransform = 'camelCase' | 'snakeCase' | 'kebabCase' | 'pascalCase' | ((key: string) => string) ``` ### ObjectError Error type for object operations. ```ts title="Signature" interface ObjectError { readonly code: 'PATH_NOT_FOUND' | 'INVALID_PATH' | 'VALIDATION_ERROR' | 'CIRCULAR_REFERENCE' readonly message: string readonly path?: ObjectPath readonly cause?: unknown } ``` ### ObjectPath Represents a path into a nested object as an array of keys. ```ts title="Signature" type ObjectPath = readonly (string | number)[] ``` ```ts title="Example" const path: ObjectPath = ['user', 'address', 'city'] ``` ### ObjectSchema Schema for object validation. ```ts title="Signature" type ObjectSchema = { readonly [K in keyof T]: Validator | ObjectSchema } ``` ### PathValue Get the type at a given path in an object. ```ts title="Signature" type PathValue = P extends readonly [infer First, ...infer Rest] ? First extends keyof T ? Rest extends readonly any[] ? PathValue : T[First] : never : T ``` ### PlainObject A plain object (not an array, not null, not a primitive). ```ts title="Signature" type PlainObject = Record ``` ### StripEmptyOptions Options for stripEmpty operation. ```ts title="Signature" interface StripEmptyOptions { readonly stripEmptyStrings?: boolean readonly stripEmptyArrays?: boolean readonly stripEmptyObjects?: boolean readonly deep?: boolean } ``` ### TransformKeysOptions Options for transformKeys operation. ```ts title="Signature" interface TransformKeysOptions { readonly deep?: boolean readonly transformArrays?: boolean } ``` ### UnflattenOptions Options for unflattening objects. ```ts title="Signature" interface UnflattenOptions { readonly separator?: string } ``` --- # Option 24 exports from @ontologyio/receta/option. Everything exported from `@ontologyio/receta/option`. New here? Start with the [Option guide](/docs/option). ## Functions ### collect Collects an array of Options into an Option of array. If all Options are Some, returns Some with array of values. If any Option is None, returns None. This is useful for operations that must all succeed. In a pipe, write `collect()`. A bare `collect` runs correctly but loses the element type. ```ts title="Data-last" function collect(): (options: readonly Option[]) => Option ``` ```ts title="Data-first" function collect(options: readonly Option[]): Option ``` ```ts title="Example" // All Some collect([some(1), some(2), some(3)]) // => Some([1, 2, 3]) // Any None collect([some(1), none(), some(3)]) // => None // Real-world: validating multiple fields const validateForm = (data: SignupForm) => { const fields = collect([ validateEmail(data.email), validatePassword(data.password), validateAge(data.age) ]) return map(fields, ([email, password, age]) => ({ email, password, age })) } ``` **See also** `partition`. ### filter Filters an Option based on a predicate. If the Option is Some and the predicate returns true, returns the Option unchanged. If the Option is Some and the predicate returns false, returns None. If the Option is None, returns None unchanged. ```ts title="Data-first" function filter(option: Option, predicate: (value: T) => boolean): Option ``` ```ts title="Data-last" function filter(predicate: (value: T) => boolean): (option: Option) => Option ``` ```ts title="Example" // Data-first filter(some(5), x => x > 0) // => Some(5) filter(some(-5), x => x > 0) // => None filter(none(), x => x > 0) // => None // Data-last (in pipe) pipe( fromNullable(user.age), filter(age => age >= 18), map(age => `Adult: ${age}`) ) ``` **See also** `map`, `flatMap`. ### flatMap Chains Option-returning functions. If the Option is Some, applies the function to its value. If the Option is None, returns None unchanged. This is the monadic bind operation for Option, used to avoid nested Options. ```ts title="Data-first" function flatMap(option: Option, fn: (value: T) => Option): Option ``` ```ts title="Data-last" function flatMap(fn: (value: T) => Option): (option: Option) => Option ``` ```ts title="Example" // Data-first declare function findUser(id: string): Option declare function getEmail(user: User): Option flatMap(findUser('123'), getEmail) // Some('user@example.com') or None // Data-last (in pipe) pipe( findUser('123'), flatMap(getEmail), flatMap(validateEmail) ) ``` **See also** `map`, `flatten`. ### flatten Flattens a nested Option. Converts `Option>` to `Option`. In a pipe, write `flatten()`. A bare `flatten` runs correctly but loses the element type. ```ts title="Data-last" function flatten(): (option: Option>) => Option ``` ```ts title="Data-first" function flatten(option: Option>): Option ``` ```ts title="Example" // Data-first flatten(some(some(42))) // => Some(42) flatten(some(none())) // => None flatten(none()) // => None // Data-last (in pipe) pipe( some(some(5)), flatten(), map(x => x * 2) ) // => Some(10) ``` **See also** `flatMap`. ### fromNullable Converts a nullable value to an Option. If the value is not null or undefined, returns Some with the value. Otherwise, returns None. ```ts title="Signature" function fromNullable(value: T | null | undefined): Option ``` ```ts title="Example" fromNullable(42) // => Some(42) fromNullable(null) // => None fromNullable(undefined) // => None // With array find const user = fromNullable(users.find(u => u.id === id)) ``` ### fromResult Converts a Result to an Option, discarding the error. If the Result is Ok, returns Some with the value. If the Result is Err, returns None. ```ts title="Signature" function fromResult(result: Result): Option ``` ```ts title="Example" // When you don't care about the specific error const maybeNumber = fromResult(parseNumber(str)) // Ok(42) => Some(42) // Err('Invalid') => None ``` ### isNone Type guard to check if an Option is None. Narrows the type to None. ```ts title="Signature" function isNone(option: Option): option is None ``` ```ts title="Example" const opt = none() if (isNone(opt)) { console.log('No value present') } ``` ### isSome Type guard to check if an Option is Some. Narrows the type to `Some`. ```ts title="Signature" function isSome(option: Option): option is Some ``` ```ts title="Example" const opt = some(42) if (isSome(opt)) { console.log(opt.value) // TypeScript knows this is safe } ``` ### map Maps over the Some value of an Option. If the Option is Some, applies the function to its value and returns a new Some. If the Option is None, returns None unchanged. This is the functor map operation for Option. ```ts title="Data-first" function map(option: Option, fn: (value: T) => U): Option ``` ```ts title="Data-last" function map(fn: (value: T) => U): (option: Option) => Option ``` ```ts title="Example" // Data-first map(some(5), x => x * 2) // => Some(10) map(none(), (x: number) => x * 2) // => None // Data-last (in pipe) pipe( some(5), map(x => x * 2), map(x => x + 1) ) // => Some(11) ``` **See also** `flatMap`, `filter`. ### match Pattern matching for Options. Provides a clean way to handle both Some and None cases. ```ts title="Data-first" function match(option: Option, patterns: { onSome: (value: T) => R; onNone: () => R; }): R ``` ```ts title="Data-last" function match(patterns: { onSome: (value: T) => R; onNone: () => R; }): (option: Option) => R ``` ```ts title="Example" // Data-first const message = match(findUser(id), { onSome: user => `Hello, ${user.name}`, onNone: () => 'User not found' }) // Data-last (in pipe) pipe( config.get('theme'), match({ onSome: theme => applyTheme(theme), onNone: () => applyDefaultTheme() }) ) // With different return types const status = match(maybeUser, { onSome: () => 200, onNone: () => 404 }) ``` **See also** `unwrapOr`, `unwrapOrElse`. ### none Creates a None Option representing absence of a value. ```ts title="Signature" function none(): None ``` ```ts title="Example" const result = none() // => None ``` ### partition Partitions an array of Options into Some values and count of Nones. Returns a tuple of [Some values[], None count]. In a pipe, write `partition()`. A bare `partition` runs correctly but loses the element type. ```ts title="Data-last" function partition(): (options: readonly Option[]) => [T[], number] ``` ```ts title="Data-first" function partition(options: readonly Option[]): [T[], number] ``` ```ts title="Example" const results = [some(1), none(), some(2), none(), some(3)] partition(results) // => [[1, 2, 3], 2] // Real-world: processing batch operations const processUsers = async (ids: string[]) => { const results = await Promise.all( ids.map(id => fetchUser(id)) ) const [users, failedCount] = partition(results) console.log(`Processed ${users.length}, failed ${failedCount}`) return users } ``` **See also** `collect`. ### some Creates a Some Option containing a value. ```ts title="Signature" function some(value: T): Some ``` ```ts title="Example" const result = some(42) // => { _tag: 'Some', value: 42 } ``` ### tap Performs a side effect on Some value without modifying the Option. If the Option is Some, calls the function with the value. Returns the original Option unchanged. Useful for logging, debugging, or other side effects. ```ts title="Data-first" function tap(option: Option, fn: (value: T) => void): Option ``` ```ts title="Data-last" function tap(fn: (value: T) => void): (option: Option) => Option ``` ```ts title="Example" // Data-first tap(some(42), x => console.log('Value:', x)) // Logs: "Value: 42" // => Some(42) // Data-last (in pipe) pipe( findUser(id), tap(user => console.log('Found user:', user.name)), map(user => user.email) ) ``` **See also** `tapNone`, `map`. ### tapNone Performs a side effect when the Option is None. If the Option is None, calls the function. Returns the original Option unchanged. Useful for logging missing values or debugging. ```ts title="Data-first" function tapNone(option: Option, fn: () => void): Option ``` ```ts title="Data-last" function tapNone(fn: () => void): (option: Option) => Option ``` ```ts title="Example" // Data-first tapNone(none(), () => console.log('No value')) // Logs: "No value" // => None // Data-last (in pipe) pipe( findUser(id), tapNone(() => console.log('User not found')), unwrapOr(guestUser) ) ``` **See also** `tap`. ### toNullable Converts an Option to a nullable value. If the Option is Some, returns the value. If the Option is None, returns null. Use this when interfacing with APIs that expect null for missing values. In a pipe, write `toNullable()`. A bare `toNullable` runs correctly but loses the element type. ```ts title="Data-last" function toNullable(): (option: Option) => T | null ``` ```ts title="Data-first" function toNullable(option: Option): T | null ``` ```ts title="Example" // Data-first toNullable(some(42)) // => 42 toNullable(none()) // => null // Data-last (in pipe) pipe( findUser(id), map(u => u.name), toNullable() ) // 'John' or null // Interfacing with external API const apiPayload = { userId: toNullable(maybeUserId), metadata: toNullable(maybeMetadata) } ``` **See also** `fromNullable`. ### toResult Converts an Option to a Result. If the Option is Some, returns Ok with the value. If the Option is None, returns Err with the provided error. ```ts title="Data-first" function toResult(option: Option, error: E): Result ``` ```ts title="Data-last" function toResult(error: E): (option: Option) => Result ``` ```ts title="Example" import { flatMap } from '@ontologyio/receta/result' // Data-first toResult(some(42), 'No value') // => Ok(42) toResult(none(), 'No value') // => Err('No value') // Data-last (in pipe) pipe( findUser(id), toResult({ code: 'USER_NOT_FOUND', message: 'User not found' }), flatMap(user => validateUser(user)) ) // With a config lookup pipe( config.get('apiKey'), toResult('Missing API key in config') ) ``` **See also** `fromResult`. ### tryCatch Wraps a potentially throwing function in an Option. If the function executes successfully, returns Some with the value. If it throws, returns None. ```ts title="Signature" function tryCatch(fn: () => T): Option ``` ```ts title="Example" const parseJSON = (str: string) => tryCatch(() => JSON.parse(str)) parseJSON('{"name":"John"}') // => Some({ name: 'John' }) parseJSON('invalid json') // => None ``` ### unwrap Extracts the value from a Some Option or throws an error. If the Option is Some, returns the value. If the Option is None, throws an error. Use this when you're certain the value exists. Prefer unwrapOr or unwrapOrElse for safer alternatives. ```ts title="Signature" function unwrap(option: Option): T ``` ```ts title="Example" unwrap(some(42)) // => 42 unwrap(none()) // throws Error: Cannot unwrap None ``` **See also** `unwrapOr`, `unwrapOrElse`. ### unwrapOr Extracts the value from an Option or returns a default. If the Option is Some, returns the value. If the Option is None, returns the default value. ```ts title="Data-first" function unwrapOr(option: Option, defaultValue: T): T ``` ```ts title="Data-last" function unwrapOr(defaultValue: T): (option: Option) => T ``` ```ts title="Example" // Data-first unwrapOr(some(42), 0) // => 42 unwrapOr(none(), 0) // => 0 // Data-last (in pipe) pipe( findUser(id), map(u => u.name), unwrapOr('Guest') ) // 'John' or 'Guest' ``` **See also** `unwrapOrElse`, `unwrap`. ### unwrapOrElse Extracts the value from an Option or computes a default. If the Option is Some, returns the value. If the Option is None, calls the function to compute a default. Use this when the default value is expensive to compute. ```ts title="Data-first" function unwrapOrElse(option: Option, fn: () => T): T ``` ```ts title="Data-last" function unwrapOrElse(fn: () => T): (option: Option) => T ``` ```ts title="Example" // Data-first unwrapOrElse(some(42), () => expensiveComputation()) // => 42 unwrapOrElse(none(), () => expensiveComputation()) // computed value // Data-last (in pipe) pipe( config.get('timeout'), unwrapOrElse(() => DEFAULT_TIMEOUT) ) ``` **See also** `unwrapOr`, `unwrap`. ## Types ### None Represents the absence of a value. ```ts title="Signature" interface None { readonly _tag: 'None' } ``` ### Option Option type representing either a value (Some) or no value (None). ```ts title="Signature" type Option = Some | None ``` ```ts title="Example" type MaybeUser = Option const present: MaybeUser = { _tag: 'Some', value: { name: 'John' } } const absent: MaybeUser = { _tag: 'None' } ``` ### Some Represents a value that exists. ```ts title="Signature" interface Some { readonly _tag: 'Some' readonly value: T } ``` --- # Predicate 46 exports from @ontologyio/receta/predicate. Everything exported from `@ontologyio/receta/predicate`. New here? Start with the [Predicate guide](/docs/predicate). ## Functions ### always Creates a predicate that always returns true. Useful as a default or fallback predicate. ```ts title="Signature" function always(): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { always } from '@ontologyio/receta/predicate' // Keep all items R.filter([1, 2, 3], always()) // => [1, 2, 3] // Real-world: Default case in conditional filtering const shouldFilter = Math.random() > 0.5 const predicate = shouldFilter ? (n: number) => n > 5 : always() R.filter([1, 5, 10], predicate) ``` **See also** `never`. ### and Combines multiple predicates with logical AND. Returns a predicate that is true only if all predicates return true. Short-circuits on the first false result. ```ts title="Overload 1" function and(first: TypePredicate, second: TypePredicate): TypePredicate ``` ```ts title="Overload 2" function and(first: TypePredicate, second: TypePredicate, third: TypePredicate): TypePredicate ``` ```ts title="Overload 3" function and(...predicates: Predicate[]): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { gt, lt, and } from '@ontologyio/receta/predicate' // Combine comparisons const numbers = [1, 5, 10, 15, 20] R.filter(numbers, and(gt(5), lt(15))) // => [10] // Multiple conditions const users = [ { age: 25, active: true, verified: true }, { age: 30, active: false, verified: true }, { age: 35, active: true, verified: false } ] R.filter( users, and( (u) => u.age >= 25, (u) => u.active, (u) => u.verified ) ) // => [{ age: 25, active: true, verified: true }] ``` **See also** `or`, `not`. ### between Creates a predicate that tests if a value is within a range (inclusive). ```ts title="Overload 1" function between(min: number, max: number): Predicate ``` ```ts title="Overload 2" function between(min: bigint, max: bigint): Predicate ``` ```ts title="Overload 3" function between(min: T, max: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 5, 10, 15, 20, 25] R.filter(numbers, between(10, 20)) // => [10, 15, 20] // Real-world: Filter products by price range const products = [ { name: 'Budget', price: 5 }, { name: 'Standard', price: 15 }, { name: 'Premium', price: 50 } ] R.filter(products, (p) => between(10, 30)(p.price)) // => [{ name: 'Standard', price: 15 }] ``` **See also** `gt`, `lt`. ### by Creates a predicate by composing a selector function with a predicate. Useful for testing derived values or nested properties. ```ts title="Signature" function by(selector: (value: T) => U, predicate: Predicate): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { by, gt } from '@ontologyio/receta/predicate' const users = [ { name: 'Alice', tags: ['admin', 'user'] }, { name: 'Bob', tags: ['user'] }, { name: 'Charlie', tags: ['admin', 'moderator', 'user'] } ] // Test derived values R.filter( users, by((u) => u.tags.length, gt(1)) ) // => Alice and Charlie (have multiple tags) // Real-world: Filter by computed values const products = [ { name: 'A', price: 100, discount: 0.1 }, { name: 'B', price: 50, discount: 0.2 }, { name: 'C', price: 150, discount: 0 } ] R.filter( products, by((p) => p.price * (1 - p.discount), gt(80)) ) // => products with final price > 80 ``` **See also** `prop`, `where`. ### endsWith Creates a predicate that tests if a string ends with a suffix. Case-sensitive. ```ts title="Signature" function endsWith(suffix: string): Predicate ``` ```ts title="Example" import * as R from 'remeda' const files = ['app.ts', 'app.js', 'config.json', 'test.spec.ts'] R.filter(files, endsWith('.ts')) // => ['app.ts', 'test.spec.ts'] ``` **See also** `startsWith`, `includes`. ### eq Creates a predicate that tests if a value equals another value. Uses strict equality (===). ```ts title="Signature" function eq(expected: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 2, 3, 2, 1] R.filter(numbers, eq(2)) // => [2, 2] const users = [ { id: 1, status: 'active' }, { id: 2, status: 'inactive' }, { id: 3, status: 'active' } ] R.filter(users, (u) => eq('active')(u.status)) // => active users ``` **See also** `neq`. ### gt Creates a predicate that tests if a value is greater than a threshold. ```ts title="Overload 1" function gt(threshold: number): Predicate ``` ```ts title="Overload 2" function gt(threshold: bigint): Predicate ``` ```ts title="Overload 3" function gt(threshold: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 5, 10, 15, 20] R.filter(numbers, gt(10)) // => [15, 20] // With pipe R.pipe( numbers, R.filter(gt(10)) ) // => [15, 20] ``` **See also** `gte`, `lt`, `between`. ### gte Creates a predicate that tests if a value is greater than or equal to a threshold. ```ts title="Overload 1" function gte(threshold: number): Predicate ``` ```ts title="Overload 2" function gte(threshold: bigint): Predicate ``` ```ts title="Overload 3" function gte(threshold: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 5, 10, 15, 20] R.filter(numbers, gte(10)) // => [10, 15, 20] ``` **See also** `gt`, `lte`. ### hasProperty Creates a predicate that tests if an object has a specific property. ```ts title="Signature" function hasProperty(key: K): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { hasProperty } from '@ontologyio/receta/predicate' const objects = [ { name: 'Alice', age: 25 }, { name: 'Bob' }, { name: 'Charlie', age: 30 } ] R.filter(objects, hasProperty('age')) // => [{ name: 'Alice', age: 25 }, { name: 'Charlie', age: 30 }] // Real-world: Filter objects with optional properties const users = [ { id: 1, email: 'alice@example.com' }, { id: 2 }, { id: 3, email: 'charlie@example.com' } ] R.filter(users, hasProperty('email')) // => users with email ``` **See also** `prop`, `where`. ### includes Creates a predicate that tests if a string contains a substring. Case-sensitive. ```ts title="Signature" function includes(substring: string): Predicate ``` ```ts title="Example" import * as R from 'remeda' const emails = ['alice@gmail.com', 'bob@yahoo.com', 'charlie@gmail.com'] R.filter(emails, includes('@gmail.com')) // => Gmail emails // Real-world: Search functionality const users = [ { name: 'Alice Smith' }, { name: 'Bob Johnson' }, { name: 'Alice Brown' } ] const searchTerm = 'Smith' R.filter(users, (u) => includes(searchTerm)(u.name)) // => [{ name: 'Alice Smith' }] ``` **See also** `startsWith`, `endsWith`. ### isArray Type guard that checks if a value is an array. ```ts title="Signature" function isArray(value: unknown): value is unknown[] ``` ```ts title="Example" import { isArray } from '@ontologyio/receta/predicate' const value: unknown = [1, 2, 3] if (isArray(value)) { console.log(value.length) // TypeScript knows value is array } ``` **See also** `isObject`, `isFunction`. ### isBoolean Type guard that checks if a value is a boolean. Narrows the type to `boolean` in TypeScript. ```ts title="Signature" function isBoolean(value: unknown): value is boolean ``` ```ts title="Example" import * as R from 'remeda' import { isBoolean } from '@ontologyio/receta/predicate' const mixed: unknown[] = [true, 1, false, 'yes'] const booleans = R.filter(mixed, isBoolean) // type: boolean[] // => [true, false] ``` **See also** `isString`, `isNumber`. ### isDate Type guard that checks if a value is a Date instance. ```ts title="Signature" function isDate(value: unknown): value is Date ``` ```ts title="Example" import { isDate } from '@ontologyio/receta/predicate' isDate(new Date()) // => true isDate('2024-01-01') // => false isDate(Date.now()) // => false ``` ### isDefined Type guard that checks if a value is not null or undefined. Narrows out `null` and `undefined` from the type. ```ts title="Signature" function isDefined(value: T): value is NonNullable ``` ```ts title="Example" import * as R from 'remeda' import { isDefined } from '@ontologyio/receta/predicate' const values: Array = ['hello', null, 'world', undefined] const defined = R.filter(values, isDefined) // type: string[] // => ['hello', 'world'] ``` **See also** `isNullish`. ### isEmpty A predicate that tests if a value is empty. Works with strings, arrays, and objects. - Strings: empty if length === 0 - Arrays: empty if length === 0 - Objects: empty if has no own properties ```ts title="Signature" function isEmpty(value: string | readonly unknown[] | Record): boolean ``` ```ts title="Example" import * as R from 'remeda' R.filter(['', 'hello', '', 'world'], isEmpty) // => ['', ''] R.filter([[], [1, 2], []], isEmpty) // => [[], []] R.filter([{}, { a: 1 }, {}], isEmpty) // => [{}, {}] // Real-world: Remove empty form fields const formData = { name: 'Alice', email: '', bio: 'Developer' } R.pipe( R.entries(formData), R.filter(([_, value]) => !isEmpty(value)), R.fromEntries ) // => { name: 'Alice', bio: 'Developer' } ``` **See also** `isNotEmpty`. ### isError Type guard that checks if a value is an Error instance. ```ts title="Signature" function isError(value: unknown): value is Error ``` ```ts title="Example" import { isError } from '@ontologyio/receta/predicate' isError(new Error('fail')) // => true isError(new TypeError('invalid')) // => true isError('error message') // => false ``` ### isFiniteNumber Type guard that checks if a value is a finite number. Returns false for Infinity, -Infinity, and NaN. ```ts title="Signature" function isFiniteNumber(value: unknown): value is number ``` ```ts title="Example" import { isFiniteNumber } from '@ontologyio/receta/predicate' isFiniteNumber(42) // => true isFiniteNumber(Infinity) // => false isFiniteNumber(-Infinity) // => false isFiniteNumber(NaN) // => false ``` **See also** `isNumber`, `isInteger`. ### isFunction Type guard that checks if a value is a function. ```ts title="Signature" function isFunction(value: unknown): value is Function ``` ```ts title="Example" import * as R from 'remeda' import { isFunction } from '@ontologyio/receta/predicate' const mixed: unknown[] = [() => {}, 42, function() {}, 'test'] R.filter(mixed, isFunction) // type: Function[] ``` **See also** `isObject`, `isArray`. ### isInstanceOf Creates a type guard that checks if a value is an instance of a class. ```ts title="Signature" function isInstanceOf(constructor: new (...args: any[]) => T): TypePredicate ``` ```ts title="Example" import * as R from 'remeda' import { isInstanceOf } from '@ontologyio/receta/predicate' class User { constructor(public name: string) {} } const values: unknown[] = [ new User('Alice'), { name: 'Bob' }, new User('Charlie') ] const users = R.filter(values, isInstanceOf(User)) // type: User[] // => [User('Alice'), User('Charlie')] ``` ### isInteger Type guard that checks if a value is an integer. ```ts title="Signature" function isInteger(value: unknown): value is number ``` ```ts title="Example" import * as R from 'remeda' import { isInteger } from '@ontologyio/receta/predicate' const numbers = [1, 1.5, 2, 2.5, 3] R.filter(numbers, isInteger) // => [1, 2, 3] ``` **See also** `isNumber`, `isFiniteNumber`. ### isNotEmpty A predicate that tests if a value is not empty. The inverse of `isEmpty`. ```ts title="Signature" function isNotEmpty(value: string | readonly unknown[] | Record): boolean ``` ```ts title="Example" import * as R from 'remeda' R.filter(['', 'hello', '', 'world'], isNotEmpty) // => ['hello', 'world'] ``` **See also** `isEmpty`. ### isNull Type guard that checks if a value is null. ```ts title="Signature" function isNull(value: unknown): value is null ``` ```ts title="Example" import * as R from 'remeda' import { isNull } from '@ontologyio/receta/predicate' const values: unknown[] = [null, undefined, 0, ''] R.filter(values, isNull) // => [null] ``` **See also** `isUndefined`, `isNullish`, `isDefined`. ### isNullish Type guard that checks if a value is null or undefined. ```ts title="Signature" function isNullish(value: unknown): value is null | undefined ``` ```ts title="Example" import * as R from 'remeda' import { isNullish, not } from '@ontologyio/receta/predicate' const values: Array = ['hello', null, 'world', undefined] R.filter(values, not(isNullish)) // type stays (string | null | undefined)[]. isDefined narrows it // => ['hello', 'world'] ``` **See also** `isDefined`, `isNull`, `isUndefined`. ### isNumber Type guard that checks if a value is a number. Narrows the type to `number` in TypeScript. Returns false for NaN. Infinity passes: use `isFiniteNumber` to reject it too. ```ts title="Signature" function isNumber(value: unknown): value is number ``` ```ts title="Example" import * as R from 'remeda' import { isNumber } from '@ontologyio/receta/predicate' const mixed: unknown[] = [42, 'hello', 100, null] const numbers = R.filter(mixed, isNumber) // type: number[] // => [42, 100] // Type narrowing const value: unknown = 42 if (isNumber(value)) { console.log(value * 2) // TypeScript knows value is number } ``` **See also** `isFiniteNumber`, `isInteger`, `isString`. ### isObject Type guard that checks if a value is a non-null object that isn't an array. Returns false for arrays, functions and null. Class instances such as Date and Map pass. ```ts title="Signature" function isObject(value: unknown): value is Record ``` ```ts title="Example" import { isObject } from '@ontologyio/receta/predicate' isObject({}) // => true isObject({ a: 1 }) // => true isObject([]) // => false isObject(null) // => false isObject(() => {}) // => false ``` **See also** `isArray`, `isFunction`. ### isPromise Type guard that checks if a value is a Promise. ```ts title="Signature" function isPromise(value: unknown): value is Promise ``` ```ts title="Example" import { isPromise } from '@ontologyio/receta/predicate' isPromise(Promise.resolve(42)) // => true isPromise(async () => {}) // => false (it's a function) await isPromise((async () => {})()) // => true (result of calling it) ``` ### isRegExp Type guard that checks if a value is a RegExp instance. ```ts title="Signature" function isRegExp(value: unknown): value is RegExp ``` ```ts title="Example" import { isRegExp } from '@ontologyio/receta/predicate' isRegExp(/test/) // => true isRegExp(new RegExp('test')) // => true isRegExp('/test/') // => false ``` ### isString Type guard that checks if a value is a string. Narrows the type to `string` in TypeScript. ```ts title="Signature" function isString(value: unknown): value is string ``` ```ts title="Example" import * as R from 'remeda' import { isString } from '@ontologyio/receta/predicate' const mixed: unknown[] = ['hello', 42, 'world', true] const strings = R.filter(mixed, isString) // type: string[] // => ['hello', 'world'] // Type narrowing in conditionals const value: unknown = 'test' if (isString(value)) { console.log(value.toUpperCase()) // TypeScript knows value is string } ``` **See also** `isNumber`, `isBoolean`. ### isUndefined Type guard that checks if a value is undefined. ```ts title="Signature" function isUndefined(value: unknown): value is undefined ``` ```ts title="Example" import * as R from 'remeda' import { isUndefined } from '@ontologyio/receta/predicate' const values: unknown[] = [null, undefined, 0, ''] R.filter(values, isUndefined) // => [undefined] ``` **See also** `isNull`, `isNullish`, `isDefined`. ### lt Creates a predicate that tests if a value is less than a threshold. ```ts title="Overload 1" function lt(threshold: number): Predicate ``` ```ts title="Overload 2" function lt(threshold: bigint): Predicate ``` ```ts title="Overload 3" function lt(threshold: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 5, 10, 15, 20] R.filter(numbers, lt(10)) // => [1, 5] // Useful for filtering by age, price, etc. const products = [ { name: 'A', price: 5 }, { name: 'B', price: 15 }, { name: 'C', price: 25 } ] R.filter(products, (p) => lt(20)(p.price)) // => products A and B ``` **See also** `lte`, `gt`, `between`. ### lte Creates a predicate that tests if a value is less than or equal to a threshold. ```ts title="Overload 1" function lte(threshold: number): Predicate ``` ```ts title="Overload 2" function lte(threshold: bigint): Predicate ``` ```ts title="Overload 3" function lte(threshold: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 5, 10, 15, 20] R.filter(numbers, lte(10)) // => [1, 5, 10] ``` **See also** `lt`, `gte`. ### matches Creates a predicate that tests if a string matches a regular expression. ```ts title="Signature" function matches(pattern: RegExp): Predicate ``` ```ts title="Example" import * as R from 'remeda' const emails = ['alice@example.com', 'invalid-email', 'bob@test.org'] const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ R.filter(emails, matches(emailPattern)) // => valid emails // Real-world: Validate input format const phoneNumbers = ['+1-555-0100', '555-0100', 'not-a-phone'] R.filter(phoneNumbers, matches(/^\+?\d{1,3}-\d{3}-\d{4}$/)) // => ['+1-555-0100'] ``` **See also** `includes`. ### matchesShape Creates a predicate that tests if an object has the property values in a pattern. Similar to `where`, but compares values with strict equality (===) instead of running predicates. Properties not in the pattern are ignored. ```ts title="Signature" function matchesShape(pattern: Partial): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { matchesShape } from '@ontologyio/receta/predicate' const events = [ { type: 'click', x: 100, y: 200 }, { type: 'keypress', key: 'Enter' }, { type: 'click', x: 150, y: 250 } ] // Find click events R.filter(events, matchesShape({ type: 'click' })) // => click events // Real-world: Pattern matching on API responses const responses = [ { status: 'success', data: { id: 1 } }, { status: 'error', message: 'Not found' }, { status: 'success', data: { id: 2 } } ] R.filter(responses, matchesShape({ status: 'success' })) // => success responses ``` **See also** `where`, `eq`. ### neq Creates a predicate that tests if a value does not equal another value. Uses strict inequality (!==). ```ts title="Signature" function neq(expected: T): Predicate ``` ```ts title="Example" import * as R from 'remeda' const numbers = [1, 2, 3, 2, 1] R.filter(numbers, neq(2)) // => [1, 3, 1] ``` **See also** `eq`. ### never Creates a predicate that always returns false. Useful as a default or fallback predicate for filtering out all items. ```ts title="Signature" function never(): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { never } from '@ontologyio/receta/predicate' // Filter out all items R.filter([1, 2, 3], never()) // => [] // Real-world: Disable filtering temporarily const isDebugMode = process.env.DEBUG === 'true' const predicate = isDebugMode ? never() : (log) => log.level === 'error' ``` **See also** `always`. ### not Negates a predicate. Returns a predicate that returns the opposite boolean value. ```ts title="Signature" function not(predicate: Predicate): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { eq, not, isEmpty } from '@ontologyio/receta/predicate' const numbers = [1, 2, 3, 2, 1] R.filter(numbers, not(eq(2))) // => [1, 3, 1] // Real-world: Filter non-empty strings const names = ['Alice', '', 'Bob', '', 'Charlie'] R.filter(names, not(isEmpty)) // => ['Alice', 'Bob', 'Charlie'] // Complex negation const users = [ { name: 'Alice', admin: true }, { name: 'Bob', admin: false }, { name: 'Charlie', admin: false } ] R.filter(users, not((u) => u.admin)) // => non-admin users ``` **See also** `and`, `or`. ### oneOf Creates a predicate that tests if a value is in a list. Uses strict equality (===) for comparison. ```ts title="Signature" function oneOf(values: readonly T[]): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { oneOf } from '@ontologyio/receta/predicate' const numbers = [1, 2, 3, 4, 5] R.filter(numbers, oneOf([1, 3, 5])) // => [1, 3, 5] // Real-world: Filter by allowed statuses const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'shipped' }, { id: 3, status: 'cancelled' }, { id: 4, status: 'delivered' } ] R.filter( orders, (o) => oneOf(['pending', 'shipped'])(o.status) ) // => pending and shipped orders // More concise with prop import { prop } from '@ontologyio/receta/predicate' R.filter(orders, prop('status', oneOf(['pending', 'shipped']))) ``` **See also** `eq`, `or`. ### or Combines multiple predicates with logical OR. Returns a predicate that is true if any predicate returns true. Short-circuits on the first true result. ```ts title="Overload 1" function or(first: TypePredicate, second: TypePredicate): TypePredicate ``` ```ts title="Overload 2" function or(first: TypePredicate, second: TypePredicate, third: TypePredicate): TypePredicate ``` ```ts title="Overload 3" function or(...predicates: Predicate[]): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { eq, or } from '@ontologyio/receta/predicate' // Match multiple values const numbers = [1, 2, 3, 4, 5] R.filter(numbers, or(eq(1), eq(3), eq(5))) // => [1, 3, 5] // Real-world: Filter by multiple statuses const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'shipped' }, { id: 3, status: 'delivered' }, { id: 4, status: 'cancelled' } ] R.filter( orders, (o) => or( eq('pending'), eq('shipped') )(o.status) ) // => pending and shipped orders ``` **See also** `and`, `not`, `oneOf`. ### prop Creates a predicate that tests a specific property of an object. ```ts title="Signature" function prop(key: K, predicate: Predicate): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { prop, gt, oneOf } from '@ontologyio/receta/predicate' const users = [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 17 }, { name: 'Charlie', age: 30 } ] // Test single property R.filter(users, prop('age', gt(18))) // => Alice and Charlie // Combine with other predicates R.filter(users, prop('name', oneOf(['Alice', 'Bob']))) // => Alice and Bob // Real-world: Nested property access const orders = [ { id: 1, user: { country: 'US' } }, { id: 2, user: { country: 'UK' } }, { id: 3, user: { country: 'US' } } ] // Use by() for nested access R.filter(orders, by((o) => o.user.country, eq('US'))) // => US orders ``` **See also** `where`, `oneOf`. ### satisfies Creates a predicate that tests if a value satisfies a custom condition. This is just an identity function that makes code more readable. ```ts title="Signature" function satisfies(predicate: Predicate): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { satisfies } from '@ontologyio/receta/predicate' const numbers = [1, 2, 3, 4, 5] R.filter(numbers, satisfies((n) => n % 2 === 0)) // => [2, 4] // Makes complex predicates more readable const users = [ { name: 'Alice', age: 25, premium: true }, { name: 'Bob', age: 17, premium: false }, { name: 'Charlie', age: 30, premium: true } ] R.filter( users, satisfies((u) => u.age >= 18 && u.premium) ) ``` **See also** `where`. ### startsWith Creates a predicate that tests if a string starts with a prefix. Case-sensitive. ```ts title="Signature" function startsWith(prefix: string): Predicate ``` ```ts title="Example" import * as R from 'remeda' const names = ['Alice', 'Bob', 'Alex', 'Barbara'] R.filter(names, startsWith('A')) // => ['Alice', 'Alex'] // Real-world: Filter files by extension const files = ['app.ts', 'test.spec.ts', 'config.json'] R.filter(files, (f) => !startsWith('test.')(f)) // => non-test files ``` **See also** `endsWith`, `includes`. ### where Creates a predicate that tests object properties against a schema. Each key in the schema maps to a predicate that tests the corresponding property. All predicates must pass for the overall predicate to return true. ```ts title="Signature" function where(schema: PredicateSchema): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { where, gt, lt, eq } from '@ontologyio/receta/predicate' interface User { age: number name: string active: boolean } const users: User[] = [ { age: 25, name: 'Alice', active: true }, { age: 17, name: 'Bob', active: true }, { age: 30, name: 'Charlie', active: false } ] // Filter with multiple conditions R.filter(users, where({ age: gt(18), active: Boolean // shorthand for (v) => Boolean(v) })) // => [{ age: 25, name: 'Alice', active: true }] // Real-world: Database-like queries const products = [ { price: 10, category: 'electronics', inStock: true }, { price: 50, category: 'electronics', inStock: false }, { price: 15, category: 'books', inStock: true } ] R.filter(products, where({ category: eq('electronics'), inStock: Boolean, price: lt(30) })) // => [{ price: 10, category: 'electronics', inStock: true }] ``` **See also** `oneOf`, `prop`. ### xor Combines multiple predicates with logical XOR (exclusive or). Returns a predicate that is true if exactly one predicate returns true. ```ts title="Signature" function xor(...predicates: Predicate[]): Predicate ``` ```ts title="Example" import * as R from 'remeda' import { gt, lt, xor } from '@ontologyio/receta/predicate' const numbers = [1, 5, 10, 15, 20] // Keep numbers that are either < 8 OR > 12, but not both R.filter(numbers, xor(lt(8), gt(12))) // => [1, 5, 15, 20] // 10 is excluded because it's neither < 8 nor > 12 // Real-world: Exclusive features (must have one but not both) const products = [ { id: 1, premium: true, trial: false }, // valid { id: 2, premium: false, trial: true }, // valid { id: 3, premium: true, trial: true }, // invalid (both) { id: 4, premium: false, trial: false } // invalid (neither) ] R.filter( products, xor((p) => p.premium, (p) => p.trial) ) // => products 1 and 2 ``` **See also** `or`, `and`. ## Types ### Predicate A function that takes a value and returns a boolean. Predicates are used for filtering, validation, and type narrowing. They can be composed using combinators like `and`, `or`, and `not`. ```ts title="Signature" type Predicate = (value: T) => boolean ``` ```ts title="Example" // Simple predicate const isPositive: Predicate = (n) => n > 0 // Type-narrowing predicate const isString: Predicate = (value): value is string => typeof value === 'string' ``` ### PredicateSchema A schema definition for the `where` function. Maps object keys to predicates that test the corresponding values. ```ts title="Signature" type PredicateSchema = { [K in keyof T]?: Predicate } ``` ```ts title="Example" interface User { age: number name: string active: boolean } const schema: PredicateSchema = { age: (n) => n >= 18, name: (s) => s.length > 0, active: Boolean } ``` ### TypePredicate A function that takes a value and returns a type predicate. Type predicates enable TypeScript to narrow types in control flow. ```ts title="Signature" type TypePredicate = (value: T) => value is U ``` ```ts title="Example" const isString: TypePredicate = (value): value is string => typeof value === 'string' const value: unknown = 'hello' if (isString(value)) { // TypeScript knows value is string here console.log(value.toUpperCase()) } ``` --- # Result 27 exports from @ontologyio/receta/result. Everything exported from `@ontologyio/receta/result`. New here? Start with the [Result guide](/docs/result). ## Functions ### collect Collects an array of Results into a single Result of an array. If all Results are Ok, returns Ok with an array of all values. If any Result is Err, returns the first Err encountered. This is useful for validating multiple independent operations and collecting all successes or short-circuiting on the first error. In a pipe, write `collect()`. A bare `collect` runs correctly but loses the element type. ```ts title="Data-last" function collect(): (results: readonly Result[]) => Result ``` ```ts title="Data-first" function collect(results: readonly Result[]): Result ``` ```ts title="Example" // All successful collect([ok(1), ok(2), ok(3)]) // => Ok([1, 2, 3]) // First error short-circuits collect([ok(1), err('fail'), ok(3)]) // => Err('fail') // Practical use case const validateUser = (data: UserInput) => pipe( [ validateName(data.name), validateEmail(data.email), validateAge(data.age) ], collect(), map(([name, email, age]) => ({ name, email, age })) ) ``` **See also** `partition`. ### err Creates a failed Result containing an error. ```ts title="Signature" function err(error: E): Err ``` ```ts title="Example" const result = err('Something went wrong') // => { _tag: 'Err', error: 'Something went wrong' } ``` ### flatMap Chains Result-returning operations (monadic bind). If the Result is Ok, applies the function to its value and returns the resulting Result. If the Result is Err, returns the Err unchanged. Use this when you have a sequence of operations that each return a Result. This prevents nesting Results (`Result, E>`). ```ts title="Data-first" function flatMap(result: Result, fn: (value: T) => Result): Result ``` ```ts title="Data-last" function flatMap(fn: (value: T) => Result): (result: Result) => Result ``` ```ts title="Example" // Data-first const parseNumber = (str: string): Result => str === '' ? err('Empty string') : ok(Number(str)) flatMap(ok('42'), parseNumber) // => Ok(42) flatMap(ok(''), parseNumber) // => Err('Empty string') flatMap(err('fail'), parseNumber) // => Err('fail') // Data-last (in pipe) - chaining operations pipe( tryCatch(() => readFile('config.json')), flatMap(str => tryCatch(() => JSON.parse(str))), flatMap(config => validateConfig(config)), map(config => config.port) ) ``` **See also** `map`, `flatten`. ### flatten Flattens a nested Result one level. Converts `Result, E>` to `Result`. In a pipe, write `flatten()`. A bare `flatten` runs correctly but loses the element type. ```ts title="Data-last" function flatten(): (result: Result, F>) => Result ``` ```ts title="Data-first" function flatten(result: Result, F>): Result ``` ```ts title="Example" // Data-first flatten(ok(ok(42))) // => Ok(42) flatten(ok(err('inner'))) // => Err('inner') flatten(err('outer')) // => Err('outer') // Data-last (in pipe) pipe( ok(ok(42)), flatten() ) // => Ok(42) ``` **See also** `flatMap`. ### fromNullable Converts a nullable value to a Result. If the value is not null or undefined, returns Ok with the value. Otherwise, returns Err with the provided error. ```ts title="Data-first" function fromNullable(value: T | null | undefined, error: E): Result ``` ```ts title="Data-last" function fromNullable(error: E): (value: T | null | undefined) => Result ``` ```ts title="Example" // Data-first fromNullable(42, 'No value') // => Ok(42) fromNullable(null, 'No value') // => Err('No value') fromNullable(undefined, 'No value') // => Err('No value') // Data-last (in pipe) pipe( users.find(u => u.id === id), fromNullable('User not found'), map(user => user.name) ) // With a config lookup pipe( config.get('apiKey'), fromNullable('Missing API key in config') ) ``` ### isErr Type guard to check if a Result is Err. Narrows the type to `Err`, allowing safe access to the error property. ```ts title="Signature" function isErr(result: Result): result is Err ``` ```ts title="Example" const result: Result = err('Failed') if (isErr(result)) { console.log(result.error) // TypeScript knows this is safe } ``` ### isOk Type guard to check if a Result is Ok. Narrows the type to `Ok`, allowing safe access to the value property. ```ts title="Signature" function isOk(result: Result): result is Ok ``` ```ts title="Example" const result: Result = ok(42) if (isOk(result)) { console.log(result.value) // TypeScript knows this is safe } ``` ### map Maps over the Ok value of a Result. If the Result is Ok, applies the function to its value and returns a new Ok. If the Result is Err, returns the Err unchanged. This is the functor map operation for Result. ```ts title="Data-first" function map(result: Result, fn: (value: T) => U): Result ``` ```ts title="Data-last" function map(fn: (value: T) => U): (result: Result) => Result ``` ```ts title="Example" // Data-first map(ok(5), x => x * 2) // => Ok(10) map(err('fail'), (x: number) => x * 2) // => Err('fail') // Data-last (in pipe) pipe( ok(5), map(x => x * 2), map(x => x + 1) ) // => Ok(11) ``` **See also** `mapErr`, `flatMap`. ### mapErr Maps over the Err value of a Result. If the Result is Err, applies the function to its error and returns a new Err. If the Result is Ok, returns the Ok unchanged. Useful for transforming or enriching error values. ```ts title="Data-first" function mapErr(result: Result, fn: (error: E) => F): Result ``` ```ts title="Data-last" function mapErr(fn: (error: E) => F): (result: Result) => Result ``` ```ts title="Example" // Data-first mapErr(err('fail'), e => `Error: ${e}`) // => Err('Error: fail') mapErr(ok(5), e => `Error: ${e}`) // => Ok(5) // Data-last (in pipe) pipe( parseJSON(str), mapErr(e => ({ code: 'PARSE_ERROR', message: e.message, timestamp: Date.now() })) ) ``` **See also** `map`. ### match Pattern matches on a Result, providing handlers for both cases. This is a more functional alternative to if/else or switch statements. Both handlers must return the same type. ```ts title="Data-first" function match(result: Result, handlers: { readonly onOk: (value: T) => U; readonly onErr: (error: E) => U; }): U ``` ```ts title="Data-last" function match(handlers: { readonly onOk: (value: T) => U; readonly onErr: (error: E) => U; }): (result: Result) => U ``` ```ts title="Example" // Data-first match( parseNumber('42'), { onOk: n => `Success: ${n}`, onErr: e => `Error: ${e}` } ) // => 'Success: 42' // Data-last (in pipe) pipe( fetchUser(id), match({ onOk: user => renderUser(user), onErr: error => renderError(error) }) ) // With different handler types const getStatus = match({ onOk: () => 'success' as const, onErr: () => 'error' as const }) ``` ### ok Creates a successful Result containing a value. ```ts title="Signature" function ok(value: T): Ok ``` ```ts title="Example" const result = ok(42) // => { _tag: 'Ok', value: 42 } ``` ### orThrow Converts a Result-returning async function into a throwing variant. This is a higher-order function that wraps any async function returning `Promise>` to instead return `Promise` and throw on errors. Use this to create `*OrThrow` variants without duplicating logic. Pass a function with a single signature: an overloaded function such as `mapAsync` types as `never`, so pass its curried form instead. ```ts title="Signature" function orThrow Promise>>(fn: F): OrThrowVersion ``` ```ts title="Example" import { mapAsync } from '@ontologyio/receta/async' import { orThrow } from '@ontologyio/receta/result' // Create throwing variant const fetchAllOrThrow = orThrow( mapAsync(async (url: string) => fetch(url)) ) // Use it try { const results = await fetchAllOrThrow(urls) console.log(results) } catch (error) { console.error('Failed:', error) } ``` **See also** `unwrap`. ### parseInt Safely parses a string into an integer with optional radix. Unlike the global parseInt() which can return NaN or parse partial strings, this function returns a Result with strict validation: - Must be a valid integer - No trailing non-numeric characters allowed - Radix must be between 2 and 36 ```ts title="Signature" function parseInt(str: string, radix?: number): Result ``` **Fails with** `ParseNumberError`. ```ts title="Example" import { parseInt } from '@ontologyio/receta/result' // Decimal (default radix 10) parseInt('123') // => Ok(123) parseInt('-42') // => Ok(-42) parseInt('0') // => Ok(0) // Binary (radix 2) parseInt('1010', 2) // => Ok(10) parseInt('11111111', 2) // => Ok(255) // Hexadecimal (radix 16) parseInt('FF', 16) // => Ok(255) parseInt('0x1A', 16) // => Ok(26) // Octal (radix 8) parseInt('77', 8) // => Ok(63) // Failed parsing - not a number parseInt('abc') // Err with reason 'not_a_number' // Failed parsing - has decimal point parseInt('123.45') // Err with reason 'invalid_integer' // Failed parsing - invalid radix parseInt('123', 1) // Err with reason 'out_of_radix_range' parseInt('123', 37) // Err with reason 'out_of_radix_range' // Use with pattern matching pipe( parseInt('FF', 16), match({ onOk: (n) => `Decimal: ${n}`, onErr: (e) => `Error: ${e.message}` }) ) // => 'Decimal: 255' ``` **See also** `parseNumber`. ### parseJSON Safely parses a JSON string into a typed value. Unlike JSON.parse which throws on invalid input, this function returns a Result that either contains the parsed value or a SyntaxError. ```ts title="Signature" function parseJSON(str: string): Result ``` **Fails with** `SyntaxError`. ```ts title="Example" // Successful parsing parseJSON('{"name":"John"}') // => Ok({ name: 'John' }) parseJSON('[1, 2, 3]') // => Ok([1, 2, 3]) // Failed parsing parseJSON('invalid json') // Err(SyntaxError) parseJSON('{"incomplete":') // Err(SyntaxError) // With type annotation interface User { name: string age: number } const result = parseJSON('{"name":"Alice","age":30}') // => Ok({ name: 'Alice', age: 30 }) // Use with pipe and map pipe( parseJSON('{"name":"Bob","age":25}'), map(user => user.name.toUpperCase()) ) // => Ok('BOB') ``` ### parseNumber Safely parses a string into a number. Unlike Number() which can produce NaN or Infinity, this function returns a Result that either contains a valid finite number or a descriptive error. ```ts title="Signature" function parseNumber(str: string): Result ``` **Fails with** `ParseNumberError`. ```ts title="Example" // Successful parsing parseNumber('123') // => Ok(123) parseNumber('3.14') // => Ok(3.14) parseNumber('-42.5') // => Ok(-42.5) parseNumber('0') // => Ok(0) // Scientific notation parseNumber('1.5e10') // => Ok(15000000000) // Failed parsing - not a number parseNumber('abc') // Err with reason 'not_a_number' parseNumber('') // Err with reason 'not_a_number' // Failed parsing - infinite parseNumber('Infinity') // Err with reason 'infinite' parseNumber('-Infinity') // Err with reason 'infinite' // Use in validation pipelines pipe( parseNumber('42'), flatMap(n => n > 0 ? ok(n) : err('Must be positive')) ) // => Ok(42) ``` **See also** `parseInt`. ### partition Separates an array of Results into Ok values and Err values. Returns a tuple of [okValues, errValues]. In a pipe, write `partition()`. A bare `partition` runs correctly but loses the element type. ```ts title="Data-last" function partition(): (results: readonly Result[]) => [T[], E[]] ``` ```ts title="Data-first" function partition(results: readonly Result[]): [T[], E[]] ``` ```ts title="Example" const results = [ ok(1), err('fail1'), ok(2), err('fail2'), ok(3) ] partition(results) // => [[1, 2, 3], ['fail1', 'fail2']] // Practical use case: bulk validation const [validUsers, errors] = pipe( rawUsers, R.map(validateUser), partition() ) if (errors.length > 0) { console.log('Validation errors:', errors) } console.log('Valid users:', validUsers) ``` **See also** `collect`. ### tap Performs a side effect with the Ok value without modifying the Result. Useful for logging, debugging, or performing side effects in a pipeline while keeping the Result unchanged. ```ts title="Data-first" function tap(result: Result, fn: (value: T) => void): Result ``` ```ts title="Data-last" function tap(fn: (value: T) => void): (result: Result) => Result ``` ```ts title="Example" // Data-first tap(ok(42), n => console.log('Got:', n)) // logs 'Got: 42', returns Ok(42) tap(err('fail'), n => console.log(n)) // no log, returns Err('fail') // Data-last (in pipe) - debugging pipe( fetchUser(id), tap(user => console.log('Fetched user:', user.name)), map(user => user.email), tap(email => console.log('Email:', email)) ) ``` **See also** `tapErr`. ### tapErr Performs a side effect with the Err value without modifying the Result. Useful for error logging or monitoring while keeping the error flowing through the pipeline. ```ts title="Data-first" function tapErr(result: Result, fn: (error: E) => void): Result ``` ```ts title="Data-last" function tapErr(fn: (error: E) => void): (result: Result) => Result ``` ```ts title="Example" // Data-first tapErr(ok(42), e => console.error(e)) // no log, returns Ok(42) tapErr(err('fail'), e => console.error('Error:', e)) // logs, returns Err('fail') // Data-last (in pipe) - error monitoring pipe( parseConfig(str), tapErr(error => { logError(error) sendToMonitoring(error) }), mapErr(error => 'Using default config due to error'), unwrapOr(defaultConfig) ) ``` **See also** `tap`. ### tryCatch Wraps a potentially throwing function in a Result. If the function executes successfully, returns Ok with the value. If it throws, returns Err with the error. Wraps a potentially throwing function in a Result with custom error mapping. ```ts title="Overload 1" function tryCatch(fn: () => T): Result ``` ```ts title="Overload 2" function tryCatch(fn: () => T, mapError: (error: unknown) => E): Result ``` ```ts title="Example" const parseJSON = (str: string) => tryCatch(() => JSON.parse(str)) parseJSON('{"name":"John"}') // => Ok({ name: 'John' }) parseJSON('invalid json') // Err(SyntaxError) ``` ```ts title="Example" const parseNumber = (str: string) => tryCatch( () => { const n = Number(str) if (Number.isNaN(n)) throw new Error('Invalid number') return n }, (e) => `Parse error: ${e}` ) ``` ### tryCatchAsync Wraps an async function in a Result, catching any errors. Wraps an async function in a Result with custom error mapping. ```ts title="Overload 1" function tryCatchAsync(fn: () => Promise): Promise> ``` ```ts title="Overload 2" function tryCatchAsync(fn: () => Promise, mapError: (error: unknown) => E): Promise> ``` ```ts title="Example" const fetchUser = async (id: string) => tryCatchAsync(async () => { const res = await fetch(`/api/users/${id}`) return res.json() }) const result = await fetchUser('123') // Ok({ name: 'John' }) or Err(NetworkError) ``` ```ts title="Example" const fetchUser = async (id: string) => tryCatchAsync( async () => { const res = await fetch(`/api/users/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, (e) => ({ code: 'FETCH_ERROR', message: String(e) }) ) ``` ### unwrap Extracts the value from an Ok Result or throws the error. **Warning**: This function throws if the Result is Err. Use `unwrapOr` or `unwrapOrElse` for safer alternatives. ```ts title="Signature" function unwrap(result: Result): T ``` ```ts title="Example" unwrap(ok(42)) // => 42 unwrap(err('fail')) // throws 'fail' ``` **See also** `unwrapOr`, `unwrapOrElse`. ### unwrapOr Extracts the value from an Ok Result or returns a default. ```ts title="Data-first" function unwrapOr(result: Result, defaultValue: T): T ``` ```ts title="Data-last" function unwrapOr(defaultValue: T): (result: Result) => T ``` ```ts title="Example" // Data-first unwrapOr(ok(42), 0) // => 42 unwrapOr(err('fail'), 0) // => 0 // Data-last (in pipe) pipe( parseNumber('abc'), unwrapOr(0) ) // => 0 ``` **See also** `unwrapOrElse`. ### unwrapOrElse Extracts the value from an Ok Result or computes a fallback from the error. Unlike `unwrapOr`, this allows you to compute the default value lazily and access the error for decision-making. ```ts title="Data-first" function unwrapOrElse(result: Result, fn: (error: E) => T): T ``` ```ts title="Data-last" function unwrapOrElse(fn: (error: E) => T): (result: Result) => T ``` ```ts title="Example" // Data-first unwrapOrElse(ok(42), () => 0) // => 42 unwrapOrElse(err('fail'), e => { console.error(e) return 0 }) // logs 'fail', returns 0 // Data-last (in pipe) pipe( fetchUser(id), unwrapOrElse(error => { logError(error) return guestUser }) ) ``` **See also** `unwrapOr`. ## Types ### Err Represents a failed computation containing an error. ```ts title="Signature" interface Err { readonly _tag: 'Err' readonly error: E } ``` ### Ok Represents a successful computation containing a value. ```ts title="Signature" interface Ok { readonly _tag: 'Ok' readonly value: T } ``` ### ParseNumberError Error returned when number parsing fails. ```ts title="Signature" interface ParseNumberError { readonly _tag: 'ParseNumberError' readonly input: string readonly reason: 'not_a_number' | 'infinite' | 'invalid_integer' | 'out_of_radix_range' readonly message: string } ``` ### Result Result type representing either success (Ok) or failure (Err). ```ts title="Signature" type Result = Ok | Err ``` ```ts title="Example" type ParseResult = Result const success: ParseResult = { _tag: 'Ok', value: 42 } const failure: ParseResult = { _tag: 'Err', error: 'Invalid number' } ``` --- # String 43 exports from @ontologyio/receta/string. Everything exported from `@ontologyio/receta/string`. ## Functions ### between Extracts text between two delimiters. Returns Some with the extracted text if both delimiters are found, None otherwise. ```ts title="Data-first" function between(start: string, end: string, str: string): Option ``` ```ts title="Data-last" function between(start: string, end: string): (str: string) => Option ``` ```ts title="Example" // Data-first between('[', ']', 'Hello [world]!') // => Some('world') between('$', '.', 'Price: $99.99') // => Some('99') between('[', ']', 'No delimiters') // => None between('[', ']', '[first] and [second]') // => Some('first') (only returns first match) // Data-last (in pipe) import { unwrapOr } from '@ontologyio/receta/option' pipe( 'User ID: {12345}', between('{', '}'), unwrapOr('unknown') ) // => '12345' ``` **See also** `words`, `lines`. ### camelCase Converts a string to camelCase. ```ts title="Signature" function camelCase(str: string): string ``` ```ts title="Example" camelCase('hello-world') // => 'helloWorld' camelCase('user_profile_page') // => 'userProfilePage' camelCase('API KEY VALUE') // => 'apiKeyValue' ``` **See also** `pascalCase`, `snakeCase`. ### capitalize Capitalizes the first character of a string. ```ts title="Signature" function capitalize(str: string, options?: CaseOptions | undefined): string ``` ```ts title="Example" capitalize('hello world') // => 'Hello world' capitalize('HELLO') // => 'HELLO' (only capitalizes first char, doesn't lowercase rest) capitalize('') // => '' ``` **See also** `titleCase`. ### escapeHtml Escapes HTML special characters. Converts characters that have special meaning in HTML to their entity equivalents: - & β†’ & - < β†’ < - > β†’ > - " β†’ " - ' β†’ ' (if escapeSingleQuote is true) ```ts title="Data-first" function escapeHtml(str: string, options?: EscapeHtmlOptions | undefined): string ``` ```ts title="Data-last" function escapeHtml(options: EscapeHtmlOptions): (str: string) => string ``` ```ts title="Example" // Data-first escapeHtml('') // => '<script>alert("XSS")</script>' escapeHtml("It's a test", { escapeSingleQuote: true }) // => 'It's a test' // Data-last (in pipe) pipe( userInput, escapeHtml({ escapeSingleQuote: true }) ) // Use in user-generated content const safeComment = escapeHtml(userComment) ``` **See also** `stripHtml`, `unescapeHtml`. ### escapeRegex Escapes special regex characters in a string to make it safe for use in RegExp. Escapes all regex metacharacters: . * + ? ^ $ \{ \} ( ) | [ ] \ This is useful when you need to use user input or arbitrary strings as literal patterns in regular expressions, preventing unintended regex behavior. ```ts title="Signature" function escapeRegex(str: string): string ``` ```ts title="Example" escapeRegex('Hello.') // => 'Hello\\.' escapeRegex('$100 (USD)') // => '\\$100 \\(USD\\)' escapeRegex('[a-z]+') // => '\\[a-z\\]\\+' // Using in regex pattern const userInput = 'example.com' const pattern = new RegExp(escapeRegex(userInput)) pattern.test('example.com') // => true pattern.test('exampleXcom') // => false (. is literal, not wildcard) // Search and replace with user input const searchTerm = '$100' const text = 'Price is $100 today' const regex = new RegExp(escapeRegex(searchTerm), 'g') text.replace(regex, '$200') // => 'Price is $200 today' ``` **See also** `highlight`. ### extract Extracts all matches of a pattern from a string. Returns an array of all matched strings. ```ts title="Data-first" function extract(str: string, pattern: RegExp): string[] ``` ```ts title="Data-last" function extract(pattern: RegExp): (str: string) => string[] ``` ```ts title="Example" // Data-first extract('Price: $10, Discount: $5', /\$(\d+)/g) // => ['$10', '$5'] extract('user@example.com and admin@test.org', /\S+@\S+/g) // => ['user@example.com', 'admin@test.org'] extract('No matches here', /\d+/g) // => [] // Data-last (in pipe) pipe( logText, extract(/\d{4}-\d{2}-\d{2}/g), // Extract dates R.unique ) ``` **See also** `between`. ### highlight Highlights matching substrings by wrapping them in HTML tags. Searches for the query string within the text and wraps all matches in HTML tags (default: ``). Useful for search results, autocomplete, and text highlighting. By default, escapes HTML to prevent XSS attacks. Set `escapeHtml: false` if the input is already trusted/sanitized. ```ts title="Data-first" function highlight(text: string, query: string, options?: HighlightOptions | undefined): string ``` ```ts title="Data-last" function highlight(query: string, options?: HighlightOptions | undefined): (text: string) => string ``` ```ts title="Example" // Data-first highlight('Hello world', 'world') // => 'Hello world' highlight('JavaScript and TypeScript', 'script') // => 'JavaScript and TypeScript' // Case-insensitive (default) highlight('Hello WORLD', 'world') // => 'Hello WORLD' // Custom tag and class highlight('Search term here', 'term', { tag: 'span', className: 'highlight' }) // => 'Search term here' // Case-sensitive highlight('Hello World', 'world', { caseInsensitive: false }) // => 'Hello World' (no match due to case) // Empty query returns original text highlight('Hello world', '') // => 'Hello world' // HTML escaping (default, prevents XSS) highlight('', 'script') // => '<script>alert("xss")</script>' // Data-last (in pipe) pipe( searchResults.map(r => r.title), R.map(highlight(searchQuery, { className: 'search-highlight' })) ) ``` **See also** `escapeRegex`, `escapeHtml`. ### initials Extracts initials from a name or phrase. Splits the input by whitespace and takes the first character of each word. Commonly used for user avatars, labels, and displays. ```ts title="Data-first" function initials(name: string, options?: InitialsOptions | undefined): string ``` ```ts title="Data-last" function initials(options?: InitialsOptions | undefined): (name: string) => string ``` ```ts title="Example" // Data-first initials('John Doe') // => 'JD' initials('Mary Jane Watson') // => 'MJW' initials('Mary Jane Watson', { maxInitials: 2 }) // => 'MJ' initials('john doe', { uppercase: false }) // => 'jd' initials(' Multiple Spaces ') // => 'MS' initials('') // => '' initials('Single') // => 'S' // Data-last (in pipe) pipe( user.fullName, initials({ maxInitials: 2 }) ) // => 'JD' // Common use case: Avatar labels const avatarLabel = pipe( 'Robert Downey Jr.', initials({ maxInitials: 2 }) ) // => 'RD' ``` **See also** `capitalize`, `words`. ### isAlphanumeric Validates if a string contains only alphanumeric characters. ```ts title="Signature" function isAlphanumeric(str: string): boolean ``` ```ts title="Example" isAlphanumeric('hello123') // => true isAlphanumeric('hello-world') // => false isAlphanumeric('123') // => true ``` ### isBlank Checks if a string is blank (empty or whitespace-only). Alias for isEmpty. ```ts title="Signature" function isBlank(str: string): boolean ``` **See also** `isEmpty`. ### isEmail Validates if a string is a valid email address. Returns Some with the email if valid, None otherwise. Uses a practical regex that covers most common email formats. ```ts title="Signature" function isEmail(str: string): Option ``` ```ts title="Example" isEmail('user@example.com') // => Some('user@example.com') isEmail('invalid.email') // => None isEmail('user+tag@domain.co.uk') // => Some('user+tag@domain.co.uk') // Use in forms const email = pipe( formData.email, isEmail, unwrapOr('Invalid email') ) ``` **See also** `isUrl`. ### isEmpty Checks if a string is empty or contains only whitespace. ```ts title="Signature" function isEmpty(str: string): boolean ``` ```ts title="Example" isEmpty('') // => true isEmpty(' ') // => true isEmpty('hello') // => false isEmpty(' hello ') // => false ``` **See also** `isBlank`. ### isHexColor Validates if a string is a valid hexadecimal color. Supports 3, 6, and 8 character hex colors with optional # prefix. ```ts title="Signature" function isHexColor(str: string): Option ``` ```ts title="Example" isHexColor('#ff0000') // => Some('#ff0000') isHexColor('fff') // => Some('fff') isHexColor('#ff00ff80') // => Some('#ff00ff80') (with alpha) isHexColor('not-a-color') // => None ``` ### isNumeric Validates if a string contains only numeric characters. ```ts title="Signature" function isNumeric(str: string): boolean ``` ```ts title="Example" isNumeric('12345') // => true isNumeric('123.45') // => false isNumeric('12a34') // => false ``` ### isUrl Validates if a string is a valid URL. Returns Some with the URL if valid, None otherwise. Supports http, https, and protocol-relative URLs. ```ts title="Signature" function isUrl(str: string): Option ``` ```ts title="Example" isUrl('https://example.com') // => Some('https://example.com') isUrl('not a url') // => None isUrl('http://localhost:3000/path') // => Some('http://localhost:3000/path') // Use in validation const website = pipe( formData.website, isUrl, match({ onSome: (url) => url, onNone: () => 'Invalid URL' }) ) ``` **See also** `isEmail`. ### kebabCase Converts a string to kebab-case. ```ts title="Signature" function kebabCase(str: string): string ``` ```ts title="Example" kebabCase('helloWorld') // => 'hello-world' kebabCase('UserProfilePage') // => 'user-profile-page' kebabCase('API_KEY_VALUE') // => 'api-key-value' ``` **See also** `slugify`, `snakeCase`. ### lines Splits a string into an array of lines. Handles different line ending styles (\n, \r\n, \r). ```ts title="Signature" function lines(str: string): string[] ``` ```ts title="Example" lines('hello\nworld') // => ['hello', 'world'] lines('line1\r\nline2\r\nline3') // => ['line1', 'line2', 'line3'] lines('single line') // => ['single line'] // Use with pipe pipe( multilineText, lines, R.filter(line => !isEmpty(line)), R.map(trim) ) ``` **See also** `words`. ### normalizeWhitespace Normalizes whitespace in a string by replacing multiple consecutive whitespace characters with a single space and trimming leading/trailing whitespace. This includes: - Multiple spaces β†’ single space - Tabs β†’ single space - Newlines β†’ single space - Mixed whitespace β†’ single space - Leading/trailing whitespace β†’ removed Useful for cleaning up user input, normalizing text for search/comparison, and formatting data from external sources. ```ts title="Signature" function normalizeWhitespace(str: string): string ``` ```ts title="Example" normalizeWhitespace('Hello world') // => 'Hello world' normalizeWhitespace(' foo\t\tbar ') // => 'foo bar' normalizeWhitespace('line1\n\nline2\n\n\nline3') // => 'line1 line2 line3' normalizeWhitespace('mixed \t\n whitespace') // => 'mixed whitespace' normalizeWhitespace('') // => '' // Useful for search normalization const searchQuery = normalizeWhitespace(userInput) // Or data cleanup const cleanedData = pipe( rawText, normalizeWhitespace, trim ) ``` **See also** `trim`, `stripHtml`. ### parseTemplate Extracts variable names from a template string. Parses `{{variable}}` placeholders and returns an array of unique variable names. ```ts title="Signature" function parseTemplate(templateStr: string): string[] ``` ```ts title="Example" parseTemplate('Hello {{name}}, you have {{count}} messages') // => ['name', 'count'] parseTemplate('{{user}} sent {{user}} a message') // => ['user'] (duplicates removed) parseTemplate('No variables here') // => [] ``` **See also** `template`. ### pascalCase Converts a string to PascalCase. ```ts title="Signature" function pascalCase(str: string): string ``` ```ts title="Example" pascalCase('hello-world') // => 'HelloWorld' pascalCase('user_profile_page') // => 'UserProfilePage' pascalCase('api key value') // => 'ApiKeyValue' ``` **See also** `camelCase`, `kebabCase`. ### pluralize Pluralizes a word based on count, with smart English pluralization rules. By default, applies standard English pluralization: - Words ending in 's', 'ss', 'sh', 'ch', 'x', 'z' β†’ add 'es' - Words ending in consonant + 'y' β†’ replace 'y' with 'ies' - Words ending in 'f' or 'fe' β†’ replace with 'ves' - All others β†’ add 's' For irregular plurals, provide a custom plural form via options. ```ts title="Data-first" function pluralize(word: string, count: number, options?: PluralizeOptions | undefined): string ``` ```ts title="Data-last" function pluralize(count: number, options?: PluralizeOptions | undefined): (word: string) => string ``` ```ts title="Example" // Data-first pluralize('item', 1) // => '1 item' pluralize('item', 5) // => '5 items' pluralize('box', 3) // => '3 boxes' pluralize('story', 2) // => '2 stories' pluralize('knife', 4) // => '4 knives' // Custom plural pluralize('person', 3, { plural: 'people' }) // => '3 people' // Word only pluralize('category', 2, { wordOnly: true }) // => 'categories' // Data-last (in pipe) pipe( users.length, (count) => pluralize('user', count) ) // => '42 users' ``` **See also** `toOrdinal`. ### slugify Converts a string to a URL-safe slug. Transforms the input by: - Converting to lowercase - Replacing spaces and special characters with hyphens - Removing consecutive hyphens - Trimming leading/trailing hyphens ```ts title="Signature" function slugify(str: string): string ``` ```ts title="Example" slugify('Hello World!') // => 'hello-world' slugify(' TypeScript & JavaScript ') // => 'typescript-javascript' slugify('10 Tips for Better Code') // => '10-tips-for-better-code' slugify('CafΓ© MΓΌnster') // => 'cafe-munster' ``` **See also** `kebabCase`. ### snakeCase Converts a string to snake_case. ```ts title="Signature" function snakeCase(str: string): string ``` ```ts title="Example" snakeCase('helloWorld') // => 'hello_world' snakeCase('UserProfilePage') // => 'user_profile_page' snakeCase('API-KEY-VALUE') // => 'api_key_value' ``` **See also** `kebabCase`, `camelCase`. ### stripHtml Removes all HTML tags from a string. Strips all HTML tags, leaving only the text content. Does not decode HTML entities - use unescapeHtml for that. ```ts title="Signature" function stripHtml(str: string): string ``` ```ts title="Example" stripHtml('

Hello world!

') // => 'Hello world!' stripHtml('Safe text') // => 'Safe text' stripHtml('No tags here') // => 'No tags here' // Use for user-generated content pipe( userComment, stripHtml, truncate({ length: 100 }) ) ``` **See also** `escapeHtml`. ### template Safely interpolates variables into a template string. Uses `{{variable}}` syntax for interpolation. Returns an error if any variables are missing from the provided values. ```ts title="Data-first" function template(templateStr: string, vars: TemplateVars): Result ``` ```ts title="Data-last" function template(vars: TemplateVars): (templateStr: string) => Result ``` **Fails with** `TemplateError`. ```ts title="Example" // Data-first template('Hello {{name}}!', { name: 'Alice' }) // => Ok('Hello Alice!') template('Hello {{name}}!', {}) // => Err({ type: 'missing_variable', variable: 'name' }) // Data-last (in pipe) pipe( 'Welcome {{user}}, you have {{count}} messages', template({ user: 'Bob', count: 5 }) ) // => Ok('Welcome Bob, you have 5 messages') ``` **See also** `parseTemplate`. ### titleCase Converts a string to Title Case. Capitalizes the first letter of each word. Words are separated by spaces. ```ts title="Signature" function titleCase(str: string, options?: CaseOptions | undefined): string ``` ```ts title="Example" titleCase('hello world') // => 'Hello World' titleCase('the quick brown fox') // => 'The Quick Brown Fox' titleCase('user-profile-page') // => 'User-profile-page' (only capitalizes after spaces) ``` **See also** `capitalize`. ### trim Trims whitespace from both ends of a string. Enhanced version of String.prototype.trim that's pipe-friendly. ```ts title="Signature" function trim(str: string): string ``` ```ts title="Example" trim(' hello ') // => 'hello' trim('\n\t test \n') // => 'test' // Use in pipes pipe( userInput, trim, truncate({ length: 50 }) ) ``` **See also** `trimStart`, `trimEnd`. ### trimEnd Trims whitespace from the end of a string. ```ts title="Signature" function trimEnd(str: string): string ``` ```ts title="Example" trimEnd(' hello ') // => ' hello' trimEnd('test\n\t ') // => 'test' ``` **See also** `trim`, `trimStart`. ### trimStart Trims whitespace from the start of a string. ```ts title="Signature" function trimStart(str: string): string ``` ```ts title="Example" trimStart(' hello ') // => 'hello ' trimStart('\n\t test') // => 'test' ``` **See also** `trim`, `trimEnd`. ### truncate Truncates a string to a maximum length. If the string exceeds the maximum length, it's truncated and an ellipsis is appended. Can optionally truncate at word boundaries to avoid cutting words. ```ts title="Data-first" function truncate(str: string, options: TruncateOptions): string ``` ```ts title="Data-last" function truncate(options: TruncateOptions): (str: string) => string ``` ```ts title="Example" // Data-first truncate('Hello world', { length: 8 }) // => 'Hello...' truncate('Hello world', { length: 8, ellipsis: '…' }) // => 'Hello w…' truncate('The quick brown fox', { length: 15, words: true }) // => 'The quick...' truncate('Short', { length: 10 }) // => 'Short' (unchanged if under limit) // Data-last (in pipe) pipe( 'A very long string that needs truncation', truncate({ length: 20, words: true }) ) // => 'A very long...' ``` **See also** `words`. ### truncateWords Truncates a string to a maximum number of words. Unlike character-based truncation, this preserves complete words and truncates at word boundaries. Words are determined by whitespace. ```ts title="Data-first" function truncateWords(str: string, maxWords: number, options?: TruncateWordsOptions | undefined): string ``` ```ts title="Data-last" function truncateWords(maxWords: number, options?: TruncateWordsOptions | undefined): (str: string) => string ``` ```ts title="Example" // Data-first truncateWords('The quick brown fox jumps', 3) // => 'The quick brown...' truncateWords('Hello world', 5) // => 'Hello world' (unchanged if under limit) truncateWords('One two three four', 2, { suffix: ' […]' }) // => 'One two […]' truncateWords('Single', 3) // => 'Single' (no suffix if not truncated) // Data-last (in pipe) pipe( 'A long article preview with many words in it', truncateWords(5) ) // => 'A long article preview with...' ``` **See also** `truncate`, `words`. ### unescapeHtml Decodes HTML entities to their character equivalents. ```ts title="Signature" function unescapeHtml(str: string): string ``` ```ts title="Example" unescapeHtml('<script>') // => '