Parse, Don't Validate
The problem with validators
Section titled “The problem with validators”A validator answers a yes/no question. That answer is immediately discarded.
# Returns true. The proof vanishes.builtins.length list > 0The caller that receives true still needs to handle the empty case downstream — either by adding a guard (re-doing the check), or by calling a partial function that might fail at runtime. The validation happened, but no structural evidence was preserved.
This is shotgun parsing: checks are scattered across the codebase rather than consolidated at the boundary.
Parsing as proof production
Section titled “Parsing as proof production”A parser does the same work as a validator, but returns the evidence instead of discarding it:
# Returns the proof as structure.bend.nonEmpty.get list# right { head = 1; tail = [2 3]; } — the non-empty proof# left [] — the empty evidenceright { head; tail; } is not just a success signal — it is the refinement. The caller who receives this value:
- Does not need to re-check emptiness.
headandtailare unconditionally available. - Cannot accidentally use an empty list. The type prevents it.
- Has the minimal, precise representation they need: not “a list that happens to be non-empty” but “a head and a tail.”
The Either type as proof carrier
Section titled “The Either type as proof carrier”Bend represents parse results with right/left:
bend.right { head = 1; tail = [2 3]; } # proof of non-emptinessbend.left [] # evidence of what failedleft carries the original failing value, not a boolean or a string message. This matters: the caller can inspect what went wrong, show it to the user, or recover from it — without losing information.
Parse at the boundary
Section titled “Parse at the boundary”The principle says: get your data into the most precise representation as quickly as possible, at the system boundary, before any of it is acted upon.
In Bend this means composing lenses at the entry point of your logic:
let configLens = bend.pipe [ (bend.attr "timeout") bend.int (bend.satisfy (n: n > 0))];inconfigLens.get rawConfig# right 30 — a positive integer, proven# left rawConfig — the exact value that failedDownstream code receives right 30 and works with an integer. It never sees the raw config again. The boundary lens is the single point of validation.
Illegal states become unrepresentable
Section titled “Illegal states become unrepresentable”Choosing the right return type eliminates entire classes of bugs:
| Validator approach | Parser approach |
|---|---|
isNonEmpty list → bool | nonEmpty.get list → right {head;tail;} |
caller must guard head | head always safe to use |
validatePort n → bool | validPort.get n → right n |
| caller must re-check range | returned n is proven in-range |
checkSchema obj → bool | record {...}.get obj → right {fields} |
| caller works on raw obj | caller works on refined obj |
The parser approach makes the proof structurally available. The validator approach makes the proof disappear.
How this shapes Bend
Section titled “How this shapes Bend”Every combinator in Bend is a parser:
bend.intdoes not returntruewhen the value is an integer — it returnsright theInteger.bend.nonEmptydoes not returntruewhen the list is non-empty — it returnsright { head; tail; }.bend.record { name = bend.str; }does not returntruewhen the schema matches — it returnsright { name = "alice"; }.
The left branch always carries the original failing value, not an error code. The right branch always carries the refined, proven value — never the raw input.
This design means that once data has passed through a lens, the rest of the program can treat it as proven. No redundant guards. No partial functions. No silent failures.