receta
Validation

Overview

Like Result, but collects *all* errors instead of stopping at the first one. Perfect for form validation where users need to see every field error at once.

The Problem: "Fix one error, submit, find another error"

The Frustrating User Experience

You've experienced this as a user filling out a form:

// Traditional validation with try/catch or Result
function validateForm(data) {
  if (data.email.length === 0) throw new Error('Email required')
  if (!data.email.includes('@')) throw new Error('Invalid email')  // Never reached!
  if (data.password.length < 8) throw new Error('Password too short')  // Never reached!
  if (!/[A-Z]/.test(data.password)) throw new Error('Need uppercase')  // Never reached!
  return data
}

// User sees: "Email required"
// User fixes email, submits again
// User sees: "Invalid email"
// User fixes email, submits again
// User sees: "Password too short"
// User fixes password, submits again
// User sees: "Need uppercase"
// ๐Ÿ˜ค User gives up

The user had 4 errors but only learned about them one at a time.

How Modern Products Handle This

Stripe Checkout (actual behavior):

When you submit invalid payment info, Stripe shows:
โŒ Email address is invalid
โŒ Card number is incomplete
โŒ Expiration date is invalid
โŒ CVC is incomplete

All at once. You fix everything, then submit.

GitHub Pull Request (actual behavior):

When PR validation fails:
โŒ Title cannot be empty
โŒ At least one reviewer is required
โŒ All CI checks must pass
โŒ Branch has merge conflicts

All errors shown immediately.

Vercel Deployment (actual behavior):

Build validation errors:
โŒ Missing environment variable: DATABASE_URL
โŒ Missing environment variable: API_KEY
โŒ Invalid Node version: requires >=18.0.0

All configuration errors at once.

The Solution: Validation with Error Accumulation

import { validate, fromPredicate } from '@ontologyio/receta/validation'

// Validation accumulates ALL errors
const validatePassword = (password: string) =>
  validate(password, [
    fromPredicate(s => s.length >= 8, 'At least 8 characters'),
    fromPredicate(s => /[A-Z]/.test(s), 'At least one uppercase'),
    fromPredicate(s => /[0-9]/.test(s), 'At least one number'),
    fromPredicate(s => /[^A-Za-z0-9]/.test(s), 'At least one special char')
  ])

validatePassword('weak')
// => Invalid([
//   'At least 8 characters',
//   'At least one uppercase',
//   'At least one number',
//   'At least one special char'
// ])
// User sees ALL 4 errors immediately! โœจ

Multi-Field Forms

Give schema one validator per field. Passing validate only a list of validators returns a validator, which is how several rules share a field.

type SignupForm = {
  email: string
  password: string
  age: number
  country: string
}

const validateForm = (form: SignupForm) =>
  schema({
    email: validate([required('Email is required'), email()]),
    password: minLength(8, 'At least 8 characters'),
    age: min(18),
    country: oneOf(['US', 'UK', 'CA', 'AU'], 'Invalid country')
  }, form)

validateForm({ email: '', password: 'weak', age: 15, country: 'XX' })
// => Invalid([
//   { field: 'email', errors: ['Email is required', 'Invalid email address'] },
//   { field: 'password', errors: ['At least 8 characters'] },
//   { field: 'age', errors: ['Must be at least 18'] },
//   { field: 'country', errors: ['Invalid country'] }
// ])
// Shows ALL field errors at once!

Validation vs Result: When to Use Which?

Result: Fail-Fast (Stop at First Error)

import { collect, ok, err } from '@ontologyio/receta/result'

// collect stops at first error
collect([
  ok(1),
  err('error1'),  // Stops here!
  err('error2'),  // Never checked
  err('error3')   // Never checked
])
// => Err('error1')

Use Result when:

  • โœ… Business logic validation (auth, permissions)
  • โœ… Sequential operations (database โ†’ API โ†’ cache)
  • โœ… Early termination is desired
  • โœ… First error is enough context

Example: User authentication

import { flatMap } from '@ontologyio/receta/result'
pipe(
  validateToken(token),           // Stop if token invalid
  flatMap(getUser),        // Stop if user not found
  flatMap(checkPermissions) // Stop if no permission
)
// No point checking permissions if token is invalid!

Validation: Error Accumulation (Collect All Errors)

import { collectErrors, invalid } from '@ontologyio/receta/validation'

// collectErrors accumulates all errors
collectErrors([
  invalid(['error1']),
  invalid(['error2']),
  invalid(['error3'])
])
// => Invalid(['error1', 'error2', 'error3'])

Use Validation when:

  • โœ… Form validation (show all field errors)
  • โœ… API request validation (return all invalid fields)
  • โœ… Bulk data validation (CSV import, batch processing)
  • โœ… Configuration validation (all missing env vars)

Example: Form submission

// User needs to see ALL errors to fix the form
collectErrors([
  validateEmail(form.email),     // Check all fields
  validatePassword(form.password),
  validateUsername(form.username),
  validateCountry(form.country)
])
// All errors at once, better UX!

Comparison Table

FeatureResultValidation
Error handlingSingle errorArray of errors
BehaviorFail-fast (stops at first)Accumulates all
Best forSequential operationsIndependent fields
Use caseBusiness logic, API callsForms, bulk validation
ExampleAuth โ†’ DB โ†’ ProcessForm with 10 fields

Real-World Use Cases

1. Form Validation

Problem: Registration form with multiple fields. Solution: Show all validation errors at once.

const validateRegistration = (form: RegistrationForm) =>
  schema({
    username: validate([required('Username is required'), minLength(3), maxLength(20)]),
    email: validate([required('Email is required'), email()]),
    password: validate([
      required('Password is required'),
      minLength(8),
      matches(/[A-Z]/, 'Need an uppercase letter'),
      matches(/[0-9]/, 'Need a number')
    ]),
    age: validate([min(18), max(120)]),
    terms: equals(true, 'You must accept the terms')
  }, form)

// Shows all invalid fields immediately
validateRegistration(formData)

2. API Request Validation

Problem: REST API needs to validate request body. Solution: Return all validation errors in single response.

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

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

// Response shows all field errors:
// { errors: [
//   { field: 'email', errors: ['Invalid email'] },
//   { field: 'age', errors: ['Must be 18+'] }
// ] }

3. CSV Import / Bulk Validation

Problem: Importing 1000 rows from CSV. Solution: Get complete error report for all invalid rows.

validateAll(csvRows, validateProduct)
// One run returns the errors from every invalid row

// Show user: "Rows 5, 23, 157, 892 have errors"
// Instead of: "Row 5 has error" (then rerun to find row 23...)

4. Configuration Validation

Problem: App startup with environment variables. Solution: Show all missing/invalid config at once.

const validateConfig = (config: AppConfig) =>
  schema({
    DATABASE_URL: validate([required('DATABASE_URL is required'), url()]),
    API_KEY: validate([required('API_KEY is required'), minLength(32)]),
    PORT: validate([integer(), between(1000, 65535)]),
    NODE_ENV: oneOf(['development', 'production', 'test'])
  }, config)

// Environment variables can be missing and are always strings
validateConfig({
  DATABASE_URL: process.env.DATABASE_URL ?? '',
  API_KEY: process.env.API_KEY ?? '',
  PORT: Number(process.env.PORT),
  NODE_ENV: process.env.NODE_ENV ?? ''
})
// Shows ALL missing env vars, not just first one

5. Multi-Step Wizards

Problem: 5-step wizard, validate all steps before final submit. Solution: Accumulate errors across all steps.

const validateWizard = (data: WizardData) =>
  schema({
    step1: validateStep1,
    step2: validateStep2,
    step3: validateStep3,
    step4: validateStep4,
    step5: validateStep5
  }, data)

validateWizard(wizardData)
// Each error's `field` names a step that failed

// Show: "Steps 2 and 4 have errors"
// User can jump directly to those steps

Mental Model: Quality Control Inspector

Think of Validation like a quality control inspector checking products on an assembly line:

Result (Fail-Fast Inspector):

Inspector checks products one by one:
โœ“ Product 1: OK
โŒ Product 2: Defect found! STOP LINE!
  (Products 3, 4, 5 never checked)

Good when: One defect means stop everything

Validation (Thorough Inspector):

Inspector checks ALL products first:
โœ“ Product 1: OK
โŒ Product 2: Defect A
โœ“ Product 3: OK
โŒ Product 4: Defects B, C
โŒ Product 5: Defect D

Report: "Found 4 defects across 3 products: A, B, C, D"

Good when: Need complete report before fixing

Core Concepts

1. Error Accumulation

// Multiple validators on single value
validate(password, [
  validator1,  // โŒ Fails
  validator2,  // โŒ Fails
  validator3   // โœ“ Passes
])
// => Accumulates errors from validator1 AND validator2

2. Field-Level Errors

// Object validation with field tracking
schema(userSchema, userData)
// => Invalid([
//   { field: 'email', errors: ['Invalid'] },
//   { field: 'age', errors: ['Too young'] }
// ])
// Knows WHICH fields failed

3. Composable Validators

// Build complex validators from simple ones
const strongPassword = validate([
  required('Password is required'),
  minLength(8),
  matches(/[A-Z]/, 'Need an uppercase letter'),
  matches(/[0-9]/, 'Need a number'),
  matches(/[^A-Za-z0-9]/, 'Need a special character')
])

// Reuse across your app

When to Use Validation

โœ… Use Validation When:

  1. Forms with multiple fields - Show all errors at once
  2. API request validation - Return complete error details
  3. Bulk operations - Process all items, report all failures
  4. Independent validations - Each check is unrelated to others
  5. User needs full feedback - Better UX with complete error list

โŒ Don't Use Validation When:

  1. Sequential dependencies - Later steps depend on earlier ones
  2. Early termination needed - First error is enough
  3. Performance critical - Don't want to run unnecessary checks
  4. Single validation - No need for accumulation
  5. Business logic - Use Result instead

Examples:

import { flatMap } from '@ontologyio/receta/result'
// โœ… Good: Form validation (independent fields)
schema(formSchema, formData)

// โŒ Bad: Authentication chain (sequential dependencies)
// Use Result instead:
pipe(
  validateToken(token),
  flatMap(loadUser),
  flatMap(checkPermissions)
)

// โœ… Good: API batch validation
validateAll(requests, validateRequest)

// โŒ Bad: File processing pipeline
// Use Result instead:
pipe(
  readFile(path),
  flatMap(parseJSON),
  flatMap(validateSchema),
  flatMap(transform)
)

What's Next?

Now that you understand why Validation exists, learn how to use it:

  1. Constructors - Creating validations with valid, invalid, fromPredicate
  2. Validators - Built-in validators for common cases
  3. Combinators - Combining validations with collectErrors, validate, schema
  4. Patterns - Real-world recipes for forms, APIs, bulk validation
  5. Migration - Moving from try-catch, Zod, or Yup
  6. API Reference - Complete function reference, including the transformers map, mapInvalid and flatMap

Or jump straight to Common Patterns for copy-paste examples!

On this page