receta
Validation

Migration Guide

Step-by-step guide for migrating from try-catch, Zod, Yup, or other validation approaches to Receta Validation.

Table of Contents

  1. From try-catch
  2. From Zod
  3. From Yup
  4. From Result
  5. Migration Strategy

From try-catch

Before: try-catch Validation

// โŒ Old approach: Shows one error at a time
function validateUser(data: any) {
  if (!data.email) {
    throw new Error('Email is required')
  }
  if (!data.email.includes('@')) {
    throw new Error('Invalid email')  // Never reached if first check fails
  }
  if (!data.password) {
    throw new Error('Password is required')  // Never reached if email fails
  }
  if (data.password.length < 8) {
    throw new Error('Password too short')  // Never reached
  }
  return data
}

// Usage
try {
  const user = validateUser(formData)
  await saveUser(user)
} catch (error) {
  showError(error.message)  // Only shows FIRST error
}

After: Validation with Error Accumulation

// โœ… New approach: Shows ALL errors at once
import { schema, validate, required, email, minLength } from '@ontologyio/receta/validation'

const validateUser = (data: UserData) => {
  return schema({
    email: (e: string) =>
      validate(e, [
        required('Email is required'),
        email('Invalid email')
      ]),
    password: (p: string) =>
      validate(p, [
        required('Password is required'),
        minLength(8, 'Password too short')
      ])
  }, data)
}

// Usage
const validation = validateUser(formData)

match(validation, {
  onValid: async (user) => {
    await saveUser(user)
  },
  onInvalid: async (errors) => {
    // Shows ALL errors at once!
    errors.forEach(({ field, errors }) => {
      showFieldError(field, errors)
    })
  }
})

Step-by-Step Migration

Step 1: Identify validation code

// Find code like this:
if (!value) throw new Error('...')
if (condition) throw new Error('...')

Step 2: Convert to validators

// Replace with:
validate(value, [
  required('...'),
  fromPredicate(condition, '...')
])

Step 3: Group related validations

// Multiple fields? Use schema:
schema({
  field1: validator1,
  field2: validator2
}, data)

Step 4: Replace try-catch with match

// Replace:
try {
  validateUser(input)
} catch (e) {
  handleError(e)
}

// With:
match(validation, {
  onValid: handleSuccess,
  onInvalid: handleErrors
})

From Zod

Before: Zod Schema

import { z } from 'zod'

// โŒ Zod approach
const userSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores'),
  email: z.string().email('Invalid email'),
  age: z.number().int().min(18, 'Must be 18+').max(120)
})

// Usage
try {
  const user = userSchema.parse(formData)
  await saveUser(user)
} catch (error) {
  if (error instanceof z.ZodError) {
    // Zod accumulates errors too, but different API
    const fieldErrors = error.flatten().fieldErrors
    showErrors(fieldErrors)
  }
}

After: Receta Validation

import { schema, validate, required, email, minLength, maxLength, matches, min, max, integer } from '@ontologyio/receta/validation'

// โœ… Receta approach
const userSchema = {
  username: (u: string) =>
    validate(u, [
      minLength(3, 'Username must be at least 3 characters'),
      maxLength(20, 'Username must be at most 20 characters'),
      matches(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores')
    ]),
  email: (e: string) =>
    email('Invalid email')(e),
  age: (a: number) =>
    validate(a, [
      integer('Age must be an integer'),
      min(18, 'Must be 18+'),
      max(120, 'Invalid age')
    ])
}

// Usage
const validation = schema(userSchema, formData)

match(validation, {
  onValid: async (user) => {
    await saveUser(user)
  },
  onInvalid: async (errors) => {
    showErrors(errors)
  }
})

Key Differences

FeatureZodReceta Validation
API StyleMethod chainingFunction composition
Type InferenceSchema โ†’ TypeType โ†’ Schema
Pipe SupportNoYes (Remeda pipe)
Error AccumulationYesYes
Async Validation.refine() methodawait the async check, then validate
Bundle Size~13kbTree-shakeable (smaller)

Migration Steps

Step 1: Convert Zod validators to Receta

// Zod
z.string().min(3).max(20).email()

// Receta
validate(value, [minLength(3), maxLength(20), email()])

Step 2: Convert object schemas

// Zod
z.object({ field: z.string() })

// Receta
schema({ field: (v: string) => validate(v, [required('Required')]) }, data)

Step 3: Replace .parse() with match()

// Zod
try {
  const data = zodSchema.parse(input)
} catch (error) {
  // handle
}

// Receta
match(schema(userSchema, input), {
  onValid: (data) => saveUser(data),
  onInvalid: (errors) => showErrors(errors)
})

From Yup

Before: Yup Schema

import * as yup from 'yup'

// โŒ Yup approach
const userSchema = yup.object({
  username: yup.string()
    .required('Username required')
    .min(3, 'Min 3 characters')
    .max(20, 'Max 20 characters')
    .matches(/^[a-zA-Z0-9_]+$/, 'Invalid characters'),
  email: yup.string()
    .required('Email required')
    .email('Invalid email'),
  age: yup.number()
    .required('Age required')
    .integer('Must be integer')
    .min(18, 'Must be 18+')
})

// Usage
try {
  const user = await userSchema.validate(formData, { abortEarly: false })
  await saveUser(user)
} catch (error) {
  if (error instanceof yup.ValidationError) {
    const errors = error.inner.map(err => ({
      field: err.path,
      message: err.message
    }))
    showErrors(errors)
  }
}

After: Receta Validation

import { schema, validate, required, email, minLength, maxLength, matches, min, integer } from '@ontologyio/receta/validation'

// โœ… Receta approach
const userSchema = {
  username: (u: string) =>
    validate(u, [
      required('Username required'),
      minLength(3, 'Min 3 characters'),
      maxLength(20, 'Max 20 characters'),
      matches(/^[a-zA-Z0-9_]+$/, 'Invalid characters')
    ]),
  email: (e: string) =>
    validate(e, [
      required('Email required'),
      email('Invalid email')
    ]),
  age: (a: number) =>
    validate(a, [
      min(18, 'Must be 18+'),
      integer('Must be integer')
    ])
}

// Usage (synchronous by default)
const validation = schema(userSchema, formData)

match(validation, {
  onValid: async (user) => {
    await saveUser(user)
  },
  onInvalid: async (errors) => {
    showErrors(errors)
  }
})

Key Differences

FeatureYupReceta Validation
API StyleMethod chainingFunction composition
Async by DefaultYesNo (explicit async when needed)
Error AccumulationabortEarly: falseAlways accumulates
Type SafetyWeakStrong (TypeScript first)
Pipe SupportNoYes
Bundle Size~14kbSmaller (tree-shakeable)

From Result

When to Migrate from Result to Validation

Use Result when:

  • Sequential operations (auth โ†’ DB โ†’ process)
  • Early termination desired
  • First error is enough context

Migrate to Validation when:

  • Independent field validations
  • Users need to see all errors
  • Forms, APIs, bulk operations

Before: Result for Form Validation

// โŒ Result shows only first error
import { collect, map as mapResult, type Result } from '@ontologyio/receta/result'

const validateForm = (data: SignupForm): Result<SignupForm, string> =>
  pipe(
    collect([
      validateEmail(data.email),       // Only this error comes back
      validatePassword(data.password), // Dropped if email failed
      validateUsername(data.username)  // Dropped if email failed
    ]),
    mapResult(() => data)
  )

// User sees only: "Invalid email"
// User fixes email, submits
// User sees: "Password too short"
// ๐Ÿ˜ค Frustrating UX!

After: Validation Shows All Errors

// โœ… Validation shows ALL errors
import { collectErrors, map, type Validation } from '@ontologyio/receta/validation'

const validateForm = (data: SignupForm): Validation<SignupForm, string> =>
  pipe(
    collectErrors([
      validateEmail(data.email),       // Checks all three
      validatePassword(data.password), // Even if email fails
      validateUsername(data.username)  // Shows all errors at once
    ]),
    map(() => data)
  )

// User sees: "Invalid email", "Password too short", "Username taken"
// User fixes everything, submits once
// โœ… Better UX!

Side-by-Side Comparison

import { collect, err } from '@ontologyio/receta/result'
// Result: Fail-fast
collect([
  err('error1'),
  err('error2'),  // Never checked
  err('error3')   // Never checked
])
// => Err('error1')

// Validation: Error accumulation
collectErrors([
  invalid('error1'),
  invalid('error2'),
  invalid('error3')
])
// => Invalid(['error1', 'error2', 'error3'])

Migration Strategy

Incremental Migration

You can migrate gradually without rewriting everything at once:

Step 1: Start with new forms

import { collect } from '@ontologyio/receta/result'
// New forms: Use Validation
const validateNewForm = (data: NewForm) => schema(newFormSchema, data)

// Existing code: Keep Result
const processData = (data: OldForm) => collect(checksFor(data))

Step 2: Migrate high-traffic forms

// Forms with user complaints about UX
const validateRegistration = (data: RegistrationForm) => schema(registrationSchema, data)
const validateCheckout = (data: CheckoutForm) => schema(checkoutSchema, data)

Step 3: Keep Result for business logic

import { flatMap as flatMapResult } from '@ontologyio/receta/result'
// Keep Result for sequential operations
const processOrder = (order: Order) =>
  pipe(
    validateAuth(order.token),
    flatMapResult(checkInventory),
    flatMapResult(chargePayment),
    flatMapResult(createShipment)
  )

Coexistence Pattern

Result and Validation work well together:

import { err, flatMap as flatMapResult, type Result } from '@ontologyio/receta/result'
// Validation for input
const validateInput = (data: UserData): Validation<UserData, FieldError<string>> => {
  return schema(userSchema, data)
}

// Result for processing
const processUser = (user: UserData): Result<ProcessedUser, ProcessError> => {
  return pipe(
    saveToDatabase(user),
    flatMapResult(sendWelcomeEmail),
    flatMapResult(createSession)
  )
}

// Combined workflow
const handleUserCreation = (data: UserData) => {
  const validation = validateInput(data)

  return match(validation, {
    onValid: (user) => processUser(user),  // Returns Result
    onInvalid: (errors) => err({ type: 'validation', errors })
  })
}

Conversion Functions

// Validation โ†’ Result (loses accumulation)
import { toResult } from '@ontologyio/receta/validation'

const validation = validateForm(data)
const result = toResult(validation)
// Invalid(['e1', 'e2']) => Err(['e1', 'e2'])

// Result โ†’ Validation
import { fromResult } from '@ontologyio/receta/validation'

const result = fetchUser(id)
const validation = fromResult(result)
// Err('error') => Invalid(['error'])

Common Migration Patterns

Pattern 1: Form Validation

// Before
function validateForm(data: any) {
  const errors: string[] = []

  if (!data.email) errors.push('Email required')
  if (data.email && !isEmail(data.email)) errors.push('Invalid email')
  if (!data.password) errors.push('Password required')
  if (data.password && data.password.length < 8) errors.push('Password too short')

  if (errors.length > 0) {
    throw new Error(errors.join(', '))
  }

  return data
}

// After
const validateForm = (data: SignupForm) =>
  schema({
    email: (e: string) =>
      validate(e, [required('Email required'), email('Invalid email')]),
    password: (p: string) =>
      validate(p, [required('Password required'), minLength(8, 'Password too short')])
  }, data)

Pattern 2: API Validation

// Before
app.post('/api/users', (req, res) => {
  const errors: any[] = []

  if (!req.body.email) errors.push({ field: 'email', message: 'Required' })
  if (!req.body.name) errors.push({ field: 'name', message: 'Required' })

  if (errors.length > 0) {
    return res.status(400).json({ errors })
  }

  const user = createUser(req.body)
  res.json(user)
})

// After
app.post('/api/users', (req, res) => {
  const validation = validateUser(req.body)

  match(validation, {
    onValid: (user) => {
      const created = createUser(user)
      res.json(created)
    },
    onInvalid: (errors) => {
      res.status(400).json({ errors })
    }
  })
})

Migration Checklist

  • Identify validation code (try-catch, if-throw, Zod, Yup)
  • Determine if Result or Validation is better fit
    • Independent fields? โ†’ Validation
    • Sequential operations? โ†’ Result
  • Convert validators to Receta validators
  • Group related validations into schemas
  • Replace error handling with match
  • Test with invalid data (ensure all errors show)
  • Update error display UI to show multiple errors
  • Remove old validation library dependencies
  • Update tests

Troubleshooting

"I'm not seeing all errors"

Check you're using Validation, not Result:

import { collect } from '@ontologyio/receta/result'
// โŒ Result stops at first error
collect(results)

// โœ… Validation accumulates all errors
collectErrors(validations)

"Too many errors showing"

That's the feature! But you can limit:

const validation = validateForm(data)

if (isInvalid(validation)) {
  // Show first 5 errors only
  const limitedErrors = validation.errors.slice(0, 5)
  showErrors(limitedErrors)

  if (validation.errors.length > 5) {
    showMessage(`... and ${validation.errors.length - 5} more errors`)
  }
}

"How do I validate dependent fields?"

Use flatMap for sequential validation:

pipe(
  validatePassword(data.password),
  flatMap((password) =>
    data.confirmPassword === password
      ? valid(password)
      : invalid('Passwords must match')
  )
)

Next Steps

  • Patterns - Real-world validation recipes
  • API Reference - Complete function reference
  • Examples - Runnable examples

On this page