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
function tap<T>(fn: (value: T) => void): (value: T) => TBasic Usage
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
)
// => 84Debugging Pipelines
This is tap's killer use case:
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
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
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
function tryCatch<Args extends readonly any[], T, E = unknown>(
fn: (...args: Args) => T,
onError?: (error: unknown) => E
): (...args: Args) => Result<T, E>Basic Usage
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
// 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
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
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
function memoize<Args extends readonly [unknown, ...unknown[]], R>(
fn: (...args: Args) => R,
options?: MemoizeOptions
): MemoizedFunction<Args, R>Basic Usage
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 = 2Classic Example: Fibonacci
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') // ~0msReal-World Example: API Client
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:
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:
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
// ✅ 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
// ✅ 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
/**
* 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
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 - Combine with Result and Option
- Real-World Patterns - Production examples
- API Reference - Complete function signatures