receta
Result

Combinators

Working with Multiple Results

How to handle arrays of Results, bulk validation, and parallel operations.

Overview

FunctionPurposeReal-World Analogy
collect(results)All-or-nothing validationStripe: charge only if ALL validations pass
partition(results)Separate successes from failuresBulk import: show which succeeded/failed
fromNullable(value, error)Convert nullable to ResultArray.find() → Result

collect(results) - All or Nothing

When to use: Multi-field validation, dependent operations that all must succeed.

Real-World: Form Validation (Stripe Checkout)

import { collect } from '@ontologyio/receta/result'

interface CheckoutForm {
  email: string
  cardNumber: string
  cvv: string
  zipCode: string
}

type ValidationError = string

const validateCheckout = (
  form: CheckoutForm
): Result<CheckoutForm, ValidationError> => {
  const results = [
    validateEmail(form.email),
    validateCardNumber(form.cardNumber),
    validateCVV(form.cvv),
    validateZipCode(form.zipCode)
  ]

  return pipe(
    collect(results), // Fails on FIRST error
    map(([email, cardNumber, cvv, zipCode]) => ({
      email,
      cardNumber,
      cvv,
      zipCode
    }))
  )
}

// Usage
const result = validateCheckout(form)
match(result, {
  onOk: (valid) => {
    // All fields valid, proceed to payment
    processPayment(valid)
  },
  onErr: (error) => {
    // Show FIRST error to user
    showError(error)
  }
})

Real-World: Database Transaction (All or Nothing)

// Create user with profile and preferences atomically
const createUserWithProfile = async (data: SignupData) => {
  const operations = [
    createUser(data.email, data.password),
    createProfile(data.name, data.bio),
    createPreferences(data.theme, data.notifications)
  ]

  // ALL must succeed or transaction rolls back
  const created = collect(await Promise.all(operations))

  if (isErr(created)) {
    // Rollback all operations
    await rollbackTransaction()
    return created
  }

  const [user, profile, prefs] = created.value

  return ok({
    userId: user.id,
    profileId: profile.id,
    prefsId: prefs.id
  })
}

Real-World: Multi-Step API Workflow

// GitHub: Create repo with initial files
const createRepoWithFiles = async (
  name: string,
  files: Array<{ path: string; content: string }>
) => {
  // Step 1: Create repo
  const repo = await createRepo(name)

  if (isErr(repo)) {
    return repo
  }

  // Step 2: Upload all files (all must succeed)
  const fileUploads = files.map(file =>
    uploadFile(repo.value.id, file.path, file.content)
  )

  return pipe(
    await Promise.all(fileUploads),
    collect(), // All files must upload successfully
    map(uploads => ({
      repo: repo.value,
      files: uploads
    }))
  )
}

Real-World: Configuration Validation

// Required env vars - all must be present
const loadConfig = (): Result<Config, string> => {
  const required = [
    fromNullable(process.env.DATABASE_URL, 'Missing DATABASE_URL'),
    fromNullable(process.env.API_KEY, 'Missing API_KEY'),
    fromNullable(process.env.SECRET_KEY, 'Missing SECRET_KEY')
  ]

  return pipe(
    collect(required),
    map(([databaseUrl, apiKey, secretKey]) => ({
      databaseUrl,
      apiKey,
      secretKey
    }))
  )
}

// App won't start if ANY env var is missing
const config = unwrap(loadConfig()) // Safe to unwrap in startup

Key Point: collect short-circuits on the first error. If you need all errors, use partition.


partition(results) - Separate Good from Bad

When to use: Bulk operations where you want to see all successes AND all failures.

Real-World: Bulk User Import (CSV Upload)

import { partition } from '@ontologyio/receta/result'

const importUsers = async (csvRows: string[][]) => {
  // Validate each row
  const results = csvRows.map((row, index) =>
    mapErr(validateUserRow(row), error => ({
      row: index + 1,
      error
    }))
  )

  const [validUsers, errors] = partition(results)

  // Save all valid users
  await db.user.createMany({ data: validUsers })

  // Return report
  return {
    imported: validUsers.length,
    failed: errors.length,
    errors: errors.map(e => `Row ${e.row}: ${e.error}`)
  }
}

// Response:
// {
//   imported: 47,
//   failed: 3,
//   errors: [
//     'Row 5: Invalid email',
//     'Row 12: Email already exists',
//     'Row 23: Missing required field: name'
//   ]
// }

Real-World: Stripe Batch Payments

// Process multiple payments, track successes and failures
const batchCharge = async (charges: ChargeRequest[]) => {
  const results = await Promise.all(
    charges.map(async charge =>
      pipe(
        await processPayment(charge.customerId, charge.amount),
        map(payment => ({ customerId: charge.customerId, payment })),
        mapErr(error => ({ customerId: charge.customerId, error }))
      )
    )
  )

  const [successful, failed] = partition(results)

  // Send success receipts
  await Promise.all(
    successful.map(({ customerId, payment }) =>
      sendReceipt(customerId, payment.id)
    )
  )

  // Alert about failures
  if (failed.length > 0) {
    await sendAdminAlert({
      subject: `${failed.length} payments failed`,
      failures: failed
    })
  }

  return {
    successful: successful.length,
    failed: failed.length,
    failedCustomers: failed.map(f => f.customerId)
  }
}

Real-World: Image Processing Pipeline

// Resize multiple images, continue even if some fail
const resizeImages = async (images: File[]) => {
  const results = await Promise.all(
    images.map(async (file, index) =>
      pipe(
        validateImage(file),
        flatMap(valid => resizeImage(valid, { width: 800, height: 600 })),
        flatMap(resized => uploadToS3(resized)),
        mapErr(error => ({ filename: file.name, index, error }))
      )
    )
  )

  const [uploaded, failures] = partition(results)

  return {
    uploadedUrls: uploaded,
    failures: failures.map(f =>
      `${f.filename}: ${f.error.message}`
    )
  }
}

// UI shows:
// ✅ 8 images uploaded successfully
// ❌ 2 failed:
//    - photo.png: File too large
//    - sunset.gif: Invalid format (must be JPG/PNG)

Real-World: API Health Checks

// Check all services, report which are healthy/unhealthy
const healthCheck = async () => {
  const services = [
    { name: 'database', check: () => db.query('SELECT 1') },
    { name: 'redis', check: () => redis.ping() },
    { name: 'stripe', check: () => stripe.balance.retrieve() },
    { name: 's3', check: () => s3.listBuckets() }
  ]

  const results = await Promise.all(
    services.map(async ({ name, check }) =>
      pipe(
        await tryCatchAsync(check),
        map(() => name),
        mapErr((error: any) => ({ service: name, error: error.message }))
      )
    )
  )

  const [healthy, unhealthy] = partition(results)

  return {
    status: unhealthy.length === 0 ? 'healthy' : 'degraded',
    healthy,
    unhealthy: unhealthy.map(s => `${s.service}: ${s.error}`)
  }
}

// Response:
// {
//   status: 'degraded',
//   healthy: ['database', 'redis', 's3'],
//   unhealthy: ['stripe: Connection timeout']
// }

Real-World: Notification Delivery

// Send email to multiple recipients
const sendBulkEmail = async (recipients: string[], template: EmailTemplate) => {
  const results = await Promise.all(
    recipients.map(async email =>
      mapErr(await sendEmail(email, template), error => ({
        email,
        reason: error.message
      }))
    )
  )

  const [sent, failed] = partition(results)

  // Log metrics
  analytics.track('bulk_email', {
    sent: sent.length,
    failed: failed.length,
    failureRate: failed.length / recipients.length
  })

  // Retry failed emails
  if (failed.length > 0 && failed.length < 10) {
    await retryFailedEmails(failed)
  }

  return { sent, failed }
}

fromNullable(value, error) - Handle Optional Values

When to use: Array.find(), dictionary lookups, optional fields.

Real-World: Finding Records

import { fromNullable } from '@ontologyio/receta/result'

const users = [
  { id: '1', email: 'alice@example.com' },
  { id: '2', email: 'bob@example.com' }
]

const findUserById = (id: string): Result<User, string> =>
  fromNullable(
    users.find(u => u.id === id),
    `User not found: ${id}`
  )

// Usage
pipe(
  findUserById('1'),
  map(user => user.email),
  unwrapOr('noreply@example.com')
)

Real-World: Cart Item Lookup

// E-commerce: Find product in cart
const getCartItem = (cart: CartItem[], productId: string) =>
  pipe(
    fromNullable(
      cart.find(item => item.productId === productId),
      { type: 'not_in_cart', productId }
    ),
    match({
      onOk: (item) => `Quantity: ${item.quantity}`,
      onErr: () => 'Product not in cart'
    })
  )

Real-World: Route Parameter Validation

// Next.js / Express: Get required param
const getUserIdFromParams = (params: Record<string, string | undefined>) =>
  fromNullable(
    params.userId,
    { type: 'missing_param', param: 'userId' }
  )

// app.get('/api/users/:userId', (req, res) => {
//   const result = getUserIdFromParams(req.params)
//   match(result, {
//     onOk: (userId) => fetchUser(userId),
//     onErr: (error) => res.status(400).json({ error })
//   })
// })

Real-World: Lookup Tables

const HTTP_STATUS_MESSAGES: Record<number, string | undefined> = {
  200: 'OK',
  404: 'Not Found',
  500: 'Internal Server Error'
}

const getStatusMessage = (code: number) =>
  fromNullable(
    HTTP_STATUS_MESSAGES[code],
    `Unknown status code: ${code}`
  )

pipe(
  getStatusMessage(200),
  unwrapOr('Unknown')
) // 'OK'

pipe(
  getStatusMessage(418),
  unwrapOr('Unknown')
) // 'Unknown'

Combining Combinators

Pattern 1: Validate All, Then Collect

const validateAndImportUsers = async (rows: unknown[]) => {
  // Step 1: Validate all (partition to see all errors)
  const validated = rows.map(validateUser)
  const [valid, invalid] = partition(validated)

  if (invalid.length > 0) {
    return err({
      message: `${invalid.length} rows have errors`,
      errors: invalid
    })
  }

  // Step 2: Import all (collect for atomicity)
  const imported = await Promise.all(valid.map(createUser))
  return collect(imported)
}

Pattern 2: Collect Required, Partition Optional

const setupAccount = async (data: AccountData) => {
  // Required fields - all must succeed
  const required = collect([
    validateEmail(data.email),
    validatePassword(data.password),
    validateUsername(data.username)
  ])

  if (isErr(required)) {
    return required
  }

  // Optional profile fields - best effort
  const optional = partition([
    fromNullable(data.avatar, 'No avatar'),
    fromNullable(data.bio, 'No bio'),
    fromNullable(data.website, 'No website')
  ])

  const [email, password, username] = required.value
  const [profile] = optional

  return ok({ email, password, username, profile })
}

Comparison Table

ScenarioUse collectUse partitionUse fromNullable
Multi-field form✅ Stop on first error❌❌
Bulk import❌✅ Show all errors❌
Config validation✅ All required❌✅ Per field
Payment processing✅ Atomic❌❌
Health checks❌✅ Show all failures❌
Array.find()❌❌✅
Optional fields❌✅ Best effort✅ Per field

Common Patterns

1. Fail-Fast Validation

const createOrder = pipe(
  collect([
    validateUser(userId),
    validateItems(items),
    validatePayment(paymentMethod)
  ]),
  flatMap(([user, items, payment]) =>
    chargeAndCreateOrder(user, items, payment)
  )
)

2. Best-Effort Processing

const results = await processAll(items)
const [succeeded, failed] = partition(results)

const report = {
  processed: succeeded,
  failedCount: failed.length,
  retry: failed // Queue for retry
}

3. Required + Optional

const required = collect(requiredFields)
const [optional] = partition(optionalFields)

const account = pipe(
  required,
  map(fields => ({ fields, optional }))
)

Next Steps

On this page