Skip to content
Pyrula

Validated

Either short-circuits on the first error; Validated accumulates all of them.

When you validate a form, parse a config file, or check a batch of inputs, you usually want to tell the user about every problem at once, not force them to fix one error, resubmit, hit another, and repeat. That is what Validated is for.

from pyrula import Validated, Valid, Invalid

Each check returns a Valid or an Invalid. zip combines two:

  • Valid + Valid yields Valid((a, b)).
  • Valid + Invalid or Invalid + Valid yields Invalid with that side’s errors.
  • Invalid + Invalid yields Invalid with both error lists concatenated.
def check_name(name: str) -> Validated:
return (
Validated.valid(name)
if name.strip()
else Validated.invalid("name cannot be blank")
)
def check_age(age: int) -> Validated:
return (
Validated.valid(age)
if 0 <= age <= 130
else Validated.invalid("age must be between 0 and 130")
)
def check_email(email: str) -> Validated:
return (
Validated.valid(email)
if "@" in email
else Validated.invalid("email must contain @")
)
result = check_name("").zip(check_age(200)).zip(check_email("notanemail"))
# Invalid(['name cannot be blank', 'age must be between 0 and 130', 'email must contain @'])

Every error surfaces in one pass.

CallResult
Validated.valid(v) / Valid(v)Valid(v)
Validated.invalid(e)Invalid([e]) (wraps single error in a list)
Validated.invalid([e1, e2])Invalid([e1, e2]) (list stored as-is, shallow copy)
Validated.cond(test, value, error)Valid(value) if test else Invalid([error])

map runs on Valid and skips Invalid. map_err runs on Invalid’s error list.

Validated.valid(2).map(lambda x: x * 10) # Valid(20)
Validated.invalid("e").map(lambda x: x * 10) # Invalid(['e']), unchanged
Invalid("too short").map_err(lambda errs: [e.upper() for e in errs])
# Invalid(['TOO SHORT'])

fold handles both branches at the edge of a system:

result.fold(
lambda errs: f"rejected: {', '.join(errs)}",
lambda val: f"accepted: {val}",
)

get_or_else returns the value (if Valid) or the default (if Invalid).

from pyrula import Ok, Err
Validated.from_either(Ok(1)) # Valid(1)
Validated.from_either(Err("e")) # Invalid(['e'])
Valid(1).to_either() # Ok(1)
Invalid("e").to_either() # Err(['e']) (errors become the Err payload)

to_either wraps the full error list as the Err value, so the caller sees every accumulated error, not just the first.

Validated[T, E] is a real generic. Valid and Invalid are subtypes:

def validate_port(raw: str) -> Validated[int, str]:
if not raw.isdigit():
return Validated.invalid(f"not a number: {raw!r}")
port = int(raw)
return Validated.valid(port) if 0 < port < 65536 else Validated.invalid("out of range")

isinstance(x, Validated) is true for both cases.

cats.data.Validated / ValidatedNel[E, A] in Cats. ValidatedNel accumulates errors in a NonEmptyList; pyrula uses a plain list (no non-empty enforcement at the type level). The methods correspond: map, map_err (leftMap), fold, toEither. The zip operator matches Cats’ |+| / product for accumulating applicatives.

See also: Either for the short-circuiting alternative.