receta
Async

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

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

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<T>(
    endpoint: string,
    options?: RequestInit
  ): Promise<Result<T, APIError>> {
    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<T>
      },
      {
        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<User>(`/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

import { batch } from '@ontologyio/receta/async'
import { mapErr, partition } from '@ontologyio/receta/result'
import type { Result } from '@ontologyio/receta/result'

async function batchProcessWithResult<T, U>(
  items: T[],
  processor: (item: T) => Promise<Result<U, string>>,
  options: { batchSize: number; delayBetweenBatches: number }
): Promise<{ successes: U[]; failures: Array<{ item: T; error: string }> }> {
  const results: Array<Result<U, { item: T; error: string }>> = []

  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

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<Result<Dashboard, MapAsyncError>> {
  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

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:

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<T>(
  endpoints: string[]
): Promise<Result<T, string>> {
  return tryCatchAsync(
    () => Promise.any(
      endpoints.map(url => fetch(url).then(r => r.json() as Promise<T>))
    ),
    () => '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.

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<T>(
    endpoint: string
  ): Promise<Result<T, RetryError>> {
    // 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<Result<GitHubRepo[], RetryError>> {
    const repos: GitHubRepo[] = []
    let page = 1

    while (true) {
      const result = await this.fetchWithRateLimit<GitHubRepo[]>(
        `/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<GitHubRepo>(`/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.

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<string, string>
}

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<Result<StripePaymentIntent, RetryError>> {
    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<Result<StripePaymentIntent, string>> {
    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<Result<StripePaymentIntent, RetryError>> {
    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<Result<StripeCharge, RetryError>> {
    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.

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<string, string>
}

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<Result<S3Object, RetryError>> {
    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<Result<Buffer, RetryError>> {
    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<Result<S3Object[], RetryError>> {
    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>(.*?)<\/Key>/g)
      const sizeMatches = xmlText.matchAll(/<Size>(.*?)<\/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('<IsTruncated>true</IsTruncated>')
      if (!isTruncated) break

      const tokenMatch = xmlText.match(/<NextContinuationToken>(.*?)<\/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<string, string> {
    // 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<S3Object, RetryError> 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.

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<string, unknown>
}

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<Result<ImportResult, string>> {
  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<string, string> = {}
    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<string, string>
): Result<UserRecord, string> {
  // 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<Result<void, string>> {
  // 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<void, string> 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.

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<Result<SyncResult, string>> {
  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<Result<void, string>> {
  await new Promise(resolve => setTimeout(resolve, 50))
  return ok(undefined)
}

async function updateRecord(record: SyncRecord): Promise<Result<void, string>> {
  await new Promise(resolve => setTimeout(resolve, 50))
  return ok(undefined)
}

async function deleteRecord(id: string): Promise<Result<void, string>> {
  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<void, string>
  • 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.

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<Result<SendResult[], string>> {
  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<Result<string, string>> {
  // 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<string, string> 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.

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<Result<void, RetryError>> {
  // 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<string> {
  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<void, RetryError> 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.

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<Result<Job, string>> {
  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<Result<Job, string>> {
  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<Job, string> 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.

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<T>(
    fn: () => Promise<T>
  ): Promise<Result<T, string>> {
    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<string> {
  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<T, string> 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.

import { mapAsync } from '@ontologyio/receta/async'

/**
 * Benchmark different concurrency levels
 */
async function findOptimalConcurrency<T, U>(
  items: T[],
  fn: (item: T) => Promise<U>,
  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:

// Before: try-catch
async function fetchUser(id: string): Promise<User> {
  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<Result<User, RetryError>> {
  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.

On this page