Transformers
Working with Results
How to transform, chain, and compose Results without unwrapping them.
Overview
| Function | Purpose | Real-World Analogy |
|---|---|---|
map(fn) | Transform success value | Formatting API response for UI |
mapErr(fn) | Transform error value | Enriching errors with context |
flatMap(fn) | Chain operations that return Results | Multi-step API workflow |
flatten() | Unwrap nested Results | Simplifying layered operations |
tap(fn) | Side effect on success | Logging without changing value |
tapErr(fn) | Side effect on error | Error monitoring/analytics |
map(fn) - Transform Success Values
When to use: You have a Result and want to transform its value (like Array.map).
Real-World: Format API Response for UI
import { map } from '@ontologyio/receta/result'
// GitHub API returns full user object
const fetchUser = async (username: string) =>
tryCatchAsync(async () => {
const res = await fetch(`https://api.github.com/users/${username}`)
return res.json()
})
// Transform for UI (extract only what we need)
const userForUI = pipe(
await fetchUser('octocat'),
map(user => ({
name: user.name,
avatar: user.avatar_url,
followers: user.followers.toLocaleString() // Format number
}))
)
// Type: Result<{ name, avatar, followers }, unknown>Real-World: Stripe Amount Formatting
// Stripe stores amounts in cents
const getChargeAmount = (chargeId: string) =>
pipe(
fetchCharge(chargeId), // Result<Charge, StripeError>
map(charge => charge.amount), // Result<number, StripeError>
map(cents => cents / 100), // Result<number, StripeError>
map(dollars => `$${dollars.toFixed(2)}`) // Result<string, StripeError>
)
// Ok(5000) → Ok(50) → Ok('$50.00')Real-World: Database Result Transformation
// Prisma returns Date objects, frontend needs ISO strings
const getPost = async (id: string) =>
pipe(
await db.post.findUnique({ where: { id } }),
fromNullable('Post not found'),
map(post => ({
...post,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString()
}))
)Key Point: map only runs if Result is Ok. If it's Err, the error passes through unchanged.
map(ok(5), x => x * 2) // Ok(10) ✅
map(err('fail'), (x: number) => x * 2) // Err('fail') - fn never runs ✅mapErr(fn) - Transform Error Values
When to use: Enrich errors with context, normalize error formats, add tracking IDs.
Real-World: Add Context to Errors
import { mapErr } from '@ontologyio/receta/result'
const fetchUserPosts = (userId: string) =>
pipe(
fetchPosts(userId),
mapErr(error => ({
cause: error,
context: { userId, endpoint: '/posts' },
timestamp: Date.now()
}))
)Real-World: Normalize Third-Party Errors
// Stripe, AWS, GitHub all have different error formats
// Normalize them to your app's format
type AppError = {
code: string
message: string
source: 'stripe' | 'aws' | 'github'
}
const chargeCard = async (token: string) =>
pipe(
await tryCatchAsync(() => stripe.charges.create({ token })),
mapErr((stripeError: any): AppError => ({
code: stripeError.code || 'unknown',
message: stripeError.message,
source: 'stripe'
}))
)Real-World: Error Severity Levels
const categorizeError = pipe(
riskyOperation(),
mapErr((error: ApiError) => {
const severity = error.status >= 500 ? 'critical' : 'warning'
return { ...error, severity, retryable: error.status < 500 }
})
)Real-World: User-Friendly Error Messages
// Convert technical errors to user-friendly messages
const saveProfile = (profile: Profile) =>
pipe(
validateProfile(profile),
flatMap(valid => updateProfile(valid)),
mapErr(dbError => {
if (dbError.code === 'P2002') {
return 'That username is already taken'
}
if (dbError.code === 'P2003') {
return 'Invalid profile data'
}
return 'Something went wrong. Please try again.'
})
)Key Point: mapErr only runs if Result is Err. If it's Ok, the value passes through.
mapErr(ok(5), e => `Error: ${e}`) // Ok(5) - fn never runs ✅
mapErr(err('fail'), e => `Error: ${e}`) // Err('Error: fail') ✅flatMap(fn) - Chain Dependent Operations
When to use: Next operation depends on previous result AND can also fail.
Real-World: Multi-Step Workflow
import { flatMap } from '@ontologyio/receta/result'
// Stripe payment flow
const processPayment = (email: string, token: string, amount: number) =>
pipe(
// Step 1: Find or create customer
findOrCreateCustomer(email),
// Step 2: Create charge (needs customer.id)
flatMap(customer =>
createCharge(customer.id, token, amount)
),
// Step 3: Send receipt (needs charge.id)
flatMap(charge =>
sendReceipt(email, charge.id)
),
// Step 4: Format response
map(receipt => ({
success: true,
receiptUrl: receipt.url
}))
)
// If ANY step fails, pipeline short-circuits to ErrReal-World: Async Steps
flatMap is synchronous: its callback must return a Result, not a Promise of one. When a step is async, await it and return early on Err.
// User signup flow
const signupUser = async (email: string, password: string) => {
// 1. Validate email
const validEmail = validateEmail(email)
if (isErr(validEmail)) return validEmail
// 2. Hash password (can fail)
const hash = await tryCatchAsync(() => bcrypt.hash(password, 10))
if (isErr(hash)) return hash
// 3. Create user (can fail - duplicate email)
const user = await tryCatchAsync(() =>
db.user.create({ email: validEmail.value, passwordHash: hash.value })
)
if (isErr(user)) return user
return pipe(
// 4. Create session
await createSession(user.value.id),
// 5. Send welcome email (non-critical - use tap instead)
tap(() => sendWelcomeEmail(user.value.email))
)
}Real-World: API Pagination
const fetchAllPages = async (url: string) => {
let results: Item[] = []
let nextUrl: string | null = url
while (nextUrl) {
const pageUrl: string = nextUrl
const result = await tryCatchAsync(async () => {
const res = await fetch(pageUrl)
return res.json()
})
if (isErr(result)) {
return result // Propagate error
}
results = [...results, ...result.value.items]
nextUrl = result.value.next_page
}
return ok(results)
}Why flatMap vs map?
// ❌ Wrong: Using map creates nested Results
pipe(
fetchUser(id), // Result<User, Error>
map(user => fetchPosts(user.id)) // Result<Result<Post[], Error>, Error> 🤯
)
// ✅ Right: Using flatMap flattens the Result
pipe(
fetchUser(id), // Result<User, Error>
flatMap(user => fetchPosts(user.id)) // Result<Post[], Error> ✅
)flatten() - Unwrap Nested Results
When to use: You have Result<Result<T, E>, E> and want Result<T, E>.
Real-World: Conditional Validation
import { flatten } from '@ontologyio/receta/result'
const validateAge = (age: number): Result<number, string> =>
age >= 18 ? ok(age) : err('Must be 18+')
const validateUser = (user: { age?: number }) =>
pipe(
// First check if age exists
fromNullable(user.age, 'Age is required'),
// This returns Result<Result<number, string>, string>
map(age => validateAge(age)),
// Flatten to Result<number, string>
flatten()
)Real-World: Nested API Calls
// Get user's primary email from GitHub API
const getPrimaryEmail = (username: string) =>
pipe(
fetchUser(username), // Result<User, ApiError>
map(user => user.emails), // Result<Email[], ApiError>
map(emails => emails.find(e => e.primary)), // Result<Email | undefined, ApiError>
map(email => fromNullable(email, 'No primary email')), // Result<Result<Email, string>, ApiError>
flatten() // Result<Email, ApiError | string>
)tap(fn) - Side Effects on Success
When to use: Logging, analytics, notifications - things that shouldn't affect the Result.
Real-World: Logging
import { tap } from '@ontologyio/receta/result'
const createOrder = (items: Item[]) =>
pipe(
validateItems(items),
tap(valid => console.log('✓ Items validated:', valid.length)),
flatMap(calculateTotal),
tap(total => console.log('✓ Total calculated:', total)),
flatMap(chargeCustomer),
tap(charge => console.log('✓ Payment successful:', charge.id)),
flatMap(sendConfirmation),
tap(conf => console.log('✓ Confirmation sent:', conf.email))
)
// Logs appear as each step succeeds, but don't affect the ResultReal-World: Analytics
const trackUserAction = (action: string) =>
tap(data => analytics.track(action, data))
const signup = (email: string) =>
pipe(
validateEmail(email),
trackUserAction('email_validated'),
flatMap(createAccount),
trackUserAction('account_created'),
flatMap(sendWelcome),
trackUserAction('welcome_sent')
)Real-World: Progress Updates
const processLargeFile = (file: File) =>
pipe(
validateFile(file),
tap(() => updateProgress('Validating...', 10)),
flatMap(parseFile),
tap(() => updateProgress('Parsing...', 40)),
flatMap(transformData),
tap(() => updateProgress('Transforming...', 70)),
flatMap(uploadToS3),
tap(() => updateProgress('Uploading...', 90)),
map(url => ({ url, status: 'complete' })),
tap(() => updateProgress('Complete!', 100))
)Key Point: tap runs the function but returns the original Result unchanged.
pipe(
ok(42),
tap(n => console.log(n)), // Logs 42
map(n => n * 2) // Ok(84)
)
pipe(
err('fail') as Result<number, string>,
tap(n => console.log(n)), // Never runs
map(n => n * 2)
) // => Err('fail')tapErr(fn) - Side Effects on Error
When to use: Error monitoring, alerting, logging failures.
Real-World: Error Monitoring (Sentry)
import { tapErr } from '@ontologyio/receta/result'
import * as Sentry from '@sentry/node'
const processPayment = (token: string, amount: number) =>
pipe(
createCharge(token, amount),
tapErr(error => {
Sentry.captureException(error, {
tags: { amount, currency: 'usd' }
})
}),
tapErr(error => {
console.error('💳 Payment failed:', error)
})
)Real-World: Slack Alerts
const criticalOperation = () =>
pipe(
riskyDatabaseOperation(),
tapErr(error => {
if (error.severity === 'critical') {
sendSlackAlert({
channel: '#incidents',
message: `🚨 Critical error: ${error.message}`
})
}
})
)Real-World: Retry Logic
import { retry } from '@ontologyio/receta/async'
const fetchWithRetry = async (url: string, maxAttempts = 3) =>
pipe(
await retry(() => fetch(url), { maxAttempts }),
tapErr(error => {
console.error(`Gave up after ${error.attempts} attempts`, error.lastError)
})
)Cheat Sheet
// ✅ Transform success value
map(result, user => user.email)
// ✅ Transform error value
mapErr(result, error => ({ cause: error, timestamp: Date.now() }))
// ✅ Chain operations that return Results
flatMap(result, user => fetchPosts(user.id))
// ✅ Unwrap nested Results
flatten(nestedResult)
// ✅ Log success without changing value
tap(result, data => console.log(data))
// ✅ Monitor errors without changing value
tapErr(result, error => Sentry.capture(error))Common Patterns
1. Transform → Chain → Extract
const workflow = pipe(
fetchUser(id), // Get user
map(user => user.email), // Extract email
flatMap(sendResetEmail), // Send email (can fail)
map(() => 'Email sent!') // Success message
)2. Add Context to All Errors
const withContext = (operation: string) =>
mapErr(error => ({
cause: error,
operation,
timestamp: Date.now(),
environment: process.env.NODE_ENV
}))
const result = pipe(
riskyOperation(),
withContext('user_signup')
)3. Tap for Observability
const observable = pipe(
operation(),
tap(data => metrics.increment('success')),
tapErr(error => metrics.increment('error')),
tap(data => logger.info('Success', data)),
tapErr(error => logger.error('Failed', error))
)Next Steps
- Extractors: Get values out with
unwrap,match - Combinators: Work with multiple Results
- Patterns: Complete real-world recipes