ParseErrors — Structured Error Accumulation

When a user submits a form with five invalid fields, they deserve to know about all five — not just the first one you found. ParseErrors is id_effect's solution: errors accumulate across an entire parse, and you report them all at once.

ParseError vs ParseErrors

#![allow(unused)]
fn main() {
use id_effect::schema::{ParseError, ParseErrors};

// One error
let e: ParseError = ParseError::custom("age must be positive");

// Many errors
let es: ParseErrors = ParseErrors::single(e);
}

ParseError is a single failure. ParseErrors is a non-empty collection of failures with path information.

What a ParseError Contains

#![allow(unused)]
fn main() {
// A parse error has:
// - a message
// - a path (where in the data structure it occurred)
// - optionally, the value that failed

let err = ParseError::builder()
    .message("expected integer, got string")
    .path(["users", "0", "age"])
    .received(Unknown::string("thirty"))
    .build();

println!("{err}");
// → users[0].age: expected integer, got string (received: "thirty")
}

Path Tracking

Paths are built automatically as schemas descend into nested structures. You don't need to set them manually:

#![allow(unused)]
fn main() {
let raw = Unknown::from_json_str(r#"
  {
    "users": [
      { "name": "Alice", "age": 30 },
      { "name": "Bob",   "age": "thirty" }
    ]
  }
"#)?;

let result = parse(users_schema, raw);
// Err(ParseErrors {
//   errors: [
//     ParseError { path: "users[1].age", message: "expected integer" }
//   ]
// })
}

The struct_! macro and array combinator push path segments automatically. Custom schemas using .try_map or .filter inherit the current path.

Accumulation

The key property of ParseErrors is accumulation. When parsing a struct with multiple fields, failures from different fields are collected, not short-circuited:

#![allow(unused)]
fn main() {
let raw = Unknown::from_json_str(r#"
  { "name": "", "age": -5, "email": "not-an-email" }
"#)?;

let result: Result<User, ParseErrors> = parse(user_schema, raw);
// Err(ParseErrors {
//   errors: [
//     ParseError { path: "name",  message: "must not be empty" },
//     ParseError { path: "age",   message: "age must be between 0 and 150" },
//     ParseError { path: "email", message: "invalid email" },
//   ]
// })
}

All three errors reported in one call. No round-trips.

Using ParseErrors at API Boundaries

Convert ParseErrors to your API's error type:

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum ApiError {
    Validation(Vec<FieldError>),
    Internal(String),
}

#[derive(Debug)]
struct FieldError {
    field:   String,
    message: String,
}

fn to_api_errors(errs: ParseErrors) -> ApiError {
    ApiError::Validation(
        errs.into_iter()
            .map(|e| FieldError {
                field:   e.path().to_string(),
                message: e.message().to_string(),
            })
            .collect()
    )
}
}

ParseErrors in Effects

parse returns a plain Result. To lift into an Effect:

#![allow(unused)]
fn main() {
effect! {
    let raw = Unknown::from_json_bytes(&body)
        .map_err(ApiError::InvalidJson)?;

    let req = parse(create_user_schema(), raw)
        .map_err(to_api_errors)?;

    ~ create_user(req)
}
}

The ? operator on a Result<T, ParseErrors> inside effect! maps the error into E via From. Define impl From<ParseErrors> for YourError to make this ergonomic.

Displaying ParseErrors

ParseErrors implements Display with a human-readable multiline format:

Validation failed (3 errors):
  name: must not be empty
  age: age must be between 0 and 150
  email: invalid email

And Debug for the raw structure when inspecting in tests.

Summary

  • ParseError = one failure with a message and a path
  • ParseErrors = all failures from a complete parse attempt
  • Paths are tracked automatically by schema combinators
  • Accumulation means the user sees all problems at once
  • Convert to your API error type at the boundary; keep the path information