| 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... |
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.
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}`))
| 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 |
- 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