receta
Result

Transformers

Working with Results

How to transform, chain, and compose Results without unwrapping them.

Overview

FunctionPurposeReal-World Analogy
map(fn)Transform success valueFormatting API response for UI
mapErr(fn)Transform error valueEnriching errors with context
flatMap(fn)Chain operations that return ResultsMulti-step API workflow
flatten()Unwrap nested ResultsSimplifying layered operations
tap(fn)Side effect on successLogging without changing value
tapErr(fn)Side effect on errorError 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 Err

Real-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 Result

Real-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

On this page