Skip to content

Latest commit

 

History

History
356 lines (305 loc) · 9.88 KB

File metadata and controls

356 lines (305 loc) · 9.88 KB
id error-handling-aggregation
title Error Aggregation and Collection
category error-handling
skillLevel intermediate
tags
error-handling
validation
error-aggregation
batch-validation
error-collection
lessonOrder 12
rule
description
Error Aggregation and Collection using Schema.
summary Form has 10 fields. User makes mistakes in 5 of them. Current approach: show first error, user fixes it, resubmit, find next error, repeat 5 times. Bad UX. You need to collect all validation errors...

Problem

Form has 10 fields. User makes mistakes in 5 of them. Current approach: show first error, user fixes it, resubmit, find next error, repeat 5 times. Bad UX. You need to collect all validation errors at once, show them all together, and let users fix everything in one pass. Aggregating errors requires coordinating multiple validations that might independently fail.

Solution

import { Schema, Effect, Data } from "effect"

// ============================================
// 1. Define validation error collection
// ============================================

class FieldError extends Data.TaggedError("FieldError") {
  constructor(readonly field: string, readonly message: string) {
    super()
  }
}

type ValidationErrors = FieldError[]

// ============================================
// 2. Define form schema
// ============================================

const SignUpForm = Schema.Struct({
  username: Schema.String.pipe(
    Schema.minLength(3),
    Schema.maxLength(20),
    Schema.pattern(/^[a-zA-Z0-9_-]+$/)
  ),
  email: Schema.String.pipe(
    Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
  ),
  password: Schema.String.pipe(
    Schema.minLength(8)
  ),
  confirmPassword: Schema.String,
  age: Schema.Number.pipe(
    Schema.int(),
    Schema.between(13, 120)
  ),
})

type SignUpForm = typeof SignUpForm.Type

// ============================================
// 3. Individual field validators
// ============================================

const validateUsername = (value: string): Effect.Effect<string, FieldError> =>
  Effect.gen(function* () {
    if (!value || value.length < 3) {
      return yield* Effect.fail(
        new FieldError("username", "Must be at least 3 characters")
      )
    }
    if (value.length > 20) {
      return yield* Effect.fail(
        new FieldError("username", "Must be at most 20 characters")
      )
    }
    if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
      return yield* Effect.fail(
        new FieldError("username", "Only letters, numbers, hyphens, underscores allowed")
      )
    }
    return value
  })

const validateEmail = (value: string): Effect.Effect<string, FieldError> =>
  Effect.gen(function* () {
    if (!value) {
      return yield* Effect.fail(new FieldError("email", "Email required"))
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      return yield* Effect.fail(new FieldError("email", "Invalid email format"))
    }
    return value
  })

const validatePassword = (value: string): Effect.Effect<string, FieldError> =>
  Effect.gen(function* () {
    if (!value || value.length < 8) {
      return yield* Effect.fail(
        new FieldError("password", "Password must be at least 8 characters")
      )
    }
    if (!/[A-Z]/.test(value)) {
      return yield* Effect.fail(
        new FieldError("password", "Must contain uppercase letter")
      )
    }
    if (!/[0-9]/.test(value)) {
      return yield* Effect.fail(
        new FieldError("password", "Must contain number")
      )
    }
    return value
  })

const validatePasswordMatch = (
  password: string,
  confirmPassword: string
): Effect.Effect<void, FieldError> =>
  Effect.gen(function* () {
    if (password !== confirmPassword) {
      return yield* Effect.fail(
        new FieldError("confirmPassword", "Passwords do not match")
      )
    }
  })

const validateAge = (value: number): Effect.Effect<number, FieldError> =>
  Effect.gen(function* () {
    if (!Number.isInteger(value)) {
      return yield* Effect.fail(new FieldError("age", "Age must be a whole number"))
    }
    if (value < 13) {
      return yield* Effect.fail(
        new FieldError("age", "Must be at least 13 years old")
      )
    }
    if (value > 120) {
      return yield* Effect.fail(
        new FieldError("age", "Age must be realistic")
      )
    }
    return value
  })

// ============================================
// 4. Aggregate all errors
// ============================================

const validateFormErrors = (
  data: unknown
): Effect.Effect<SignUpForm, ValidationErrors> =>
  Effect.gen(function* () {
    const errors: FieldError[] = []

    // Parse input
    let parsed: any = data
    if (typeof data === "string") {
      try {
        parsed = JSON.parse(data)
      } catch {
        return yield* Effect.fail([
          new FieldError("_form", "Invalid form data"),
        ])
      }
    }

    // Validate each field and collect errors
    let username = ""
    const usernameResult = yield* validateUsername(parsed.username).pipe(
      Effect.either
    )
    if (usernameResult._tag === "Left") {
      errors.push(usernameResult.left)
    } else {
      username = usernameResult.right
    }

    let email = ""
    const emailResult = yield* validateEmail(parsed.email).pipe(Effect.either)
    if (emailResult._tag === "Left") {
      errors.push(emailResult.left)
    } else {
      email = emailResult.right
    }

    let password = ""
    const passwordResult = yield* validatePassword(parsed.password).pipe(
      Effect.either
    )
    if (passwordResult._tag === "Left") {
      errors.push(passwordResult.left)
    } else {
      password = passwordResult.right
    }

    // Validate password match only if both passwords valid
    if (password) {
      const matchResult = yield* validatePasswordMatch(
        password,
        parsed.confirmPassword
      ).pipe(Effect.either)
      if (matchResult._tag === "Left") {
        errors.push(matchResult.left)
      }
    }

    let age = 0
    const ageResult = yield* validateAge(parsed.age).pipe(Effect.either)
    if (ageResult._tag === "Left") {
      errors.push(ageResult.left)
    } else {
      age = ageResult.right
    }

    // Return all errors if any found
    if (errors.length > 0) {
      return yield* Effect.fail(errors)
    }

    return { username, email, password, confirmPassword: password, age }
  })

// ============================================
// 5. Error summary formatter
// ============================================

const formatErrorSummary = (errors: ValidationErrors): string => {
  if (errors.length === 0) return "No errors"

  const errorsByField = errors.reduce(
    (acc, error) => {
      if (!acc[error.field]) {
        acc[error.field] = []
      }
      acc[error.field].push(error.message)
      return acc
    },
    {} as Record<string, string[]>
  )

  let summary = `Found ${errors.length} validation error(s):\n`
  for (const [field, messages] of Object.entries(errorsByField)) {
    summary += `  ${field}:\n`
    for (const msg of messages) {
      summary += `    - ${msg}\n`
    }
  }
  return summary
}

// ============================================
// 6. Application logic
// ============================================

const appLogic = Effect.gen(function* () {
  console.log("=== Scenario 1: Form with many errors ===\n")

  const badFormData = {
    username: "ab",
    email: "not-an-email",
    password: "short",
    confirmPassword: "different",
    age: 10,
  }

  const result1 = yield* validateFormErrors(badFormData).pipe(
    Effect.either
  )

  if (result1._tag === "Left") {
    console.log("❌ Validation failed with multiple errors:\n")
    console.log(formatErrorSummary(result1.left))
  }

  console.log("=== Scenario 2: Form with some errors ===\n")

  const partialFormData = {
    username: "alice_123",
    email: "alice@example.com",
    password: "ValidPass123",
    confirmPassword: "DifferentPass456",
    age: 25,
  }

  const result2 = yield* validateFormErrors(partialFormData).pipe(
    Effect.either
  )

  if (result2._tag === "Left") {
    console.log("❌ Validation failed:\n")
    console.log(formatErrorSummary(result2.left))
  }

  console.log("=== Scenario 3: Valid form ===\n")

  const goodFormData = {
    username: "alice_123",
    email: "alice@example.com",
    password: "ValidPass123",
    confirmPassword: "ValidPass123",
    age: 25,
  }

  const result3 = yield* validateFormErrors(goodFormData).pipe(
    Effect.either
  )

  if (result3._tag === "Right") {
    console.log("✅ Form valid!")
    console.log(result3.right)
  }

  return result3
})

// Run application
Effect.runPromise(appLogic)
  .then(() => console.log("\n✅ Form validation complete"))
  .catch((error) => console.error(`Error: ${error.message}`))

Why This Works

Concept Explanation
Collect all errors Don't stop at first error; validate entire form
Effect.either Convert error to value for collection without failing
Array accumulation Push errors into array as they occur
Error summary Group by field for clear UI presentation
No early exit All fields validated independently
Type-safe errors Each error has field name and message
User experience Users see all problems at once, fix in one pass
Composable validators Each field has isolated validation logic

When to Use

  • Form validation showing all errors at once
  • Batch operations with per-item error tracking
  • Import/upload with errors for multiple records
  • Configuration file validation with all violations listed
  • Multi-field cross-validation (passwords match, dates align)
  • Dashboard validations showing all failed checks
  • API response validation collecting all schema violations

Related Patterns