Chapter 4 · Part II

Parse, Don't Validate: Boundaries and Trust

Untrusted input becomes a trustworthy value exactly once, at the edge — and the act that converts it should also record the proof, so the interior never has to check again.

The constrained core of Chapter 3 rests on an assumption: that the values reaching it are already the right types — a real Array[Line_Item], a real Quantity, not a blob of JSON or a half-filled form. That assumption is what lets the interior stop checking. But it has to be made true somewhere, and the only place it can be made true is the boundary — the seam where the open world hands its raw material to the closed core.

How you treat that seam decides whether the constraint discipline holds or leaks. There are two ways to meet untrusted input, and they look similar but differ in one decisive respect: what they leave behind.

4.1 Two Ways to Meet Untrusted Input

To validate is to ask a yes-or-no question and keep the same value either way. A function is_valid_quantity(raw: Integer): Boolean inspects its argument, returns true, and hands you back... the same raw Integer you started with. The knowledge it gained — that this number is positive — evaporates the moment it returns. The next function that receives the integer knows nothing, and must ask again.

To parse is to ask the same question but keep the answer in the type. A function parse_quantity(raw: Integer): Result[Quantity, String] either fails or returns a Quantity — and a Quantity, by Chapter 3's construction, cannot exist unless it is positive. The value it returns is the proof. No later function has to re-establish positivity, because holding a Quantity already means it.

The difference is not stylistic. Validation throws away evidence; parsing preserves it by moving it from a transient check into a durable type. This is why re-validation spreads through validate-style codebases like damp: because the type never changed, every layer distrusts the value equally, and the same check reappears at every level — the "shotgun parser", where input is picked at again and again because no single act ever converted it for good.

Validation asks is this value acceptable? and returns a verdict. Parsing asks what trustworthy value can I make from this? and returns the value. Only the second changes the type — and only a change of type can end the checking.

4.2 Failure as a Returnable Value

Parsing can fail — that is the whole reason it exists — so we need a way to return failure that a caller cannot ignore. Throwing an exception is the wrong tool here: an exception is an invisible exit that does not show up in the type, so a caller who forgets to handle it compiles cleanly and crashes at runtime. At a trust boundary, failure is not exceptional; it is expected, and it should be as visible as success.

So we make failure a value. Result is a sum type — exactly the construction from Chapter 3 — with two shapes: a success carrying a value, and a failure carrying an explanation. It is common enough that Nex ships it in the standard library, so we bring it in rather than redeclare it:

intern data/Result

-- Result[T, E] is one of:
--   Ok(value: T)    -- success, holding a value of type T
--   Err(error: E)   -- failure, holding an error of type E

A Result carries two type parameters: T, the value a success holds, and E, the error a failure holds. Our parsers explain themselves in prose, so E is String throughout this chapter — a Result[Quantity, String] is "a quantity, or a sentence saying why not." Keeping the error in its own parameter, separate from the value type, is what lets a failure travel upward untouched: an Err produced deep in a parse is not tied to the success type at its level, so it flows out through every enclosing parser without being taken apart and rebuilt (§4.5).

Because Result is sealed, a caller who receives one must match on it, and the compiler will not let them read the success value without also confronting the failure case (§3.4). Failure stops being a footnote and becomes a branch the type system insists you handle. The parser's whole contract — this might not work, and here is why — is now written in its return type, where no caller can miss it.

4.3 Parse Once, at the Gate

A parser is a function from a loose type to a precise one, failing when the loose value has no precise counterpart. Here is the smallest one, turning a raw integer into a Quantity:

function parse_quantity(raw: Integer): Result[Quantity, String]
  do
    if raw > 0 then
      let q: Quantity := raw                        -- narrow once, here at the gate
      result := create Ok[Quantity, String].make(q)
    else
      result := create Err[Quantity, String].make(
        "quantity must be positive, got " + raw.to_string)
    end
  end

The positivity check happens here, once, at the gate — the moment a bare Integer is narrowed into a Quantity. On the success path it produces a Quantity — and from that point on, the fact "this is positive" is carried by the type, not re-derived. The parser is the single place in the entire system where a raw integer is allowed to become a quantity, and it is the single place that check needs to live.

Everything upstream of the gate deals in loose types and distrust; everything downstream deals in precise types and trust. The parser is the pivot between the two worlds — the one function permitted to doubt, so that no function after it has to.

4.4 Why the Interior Never Re-Checks

Consider a routine deep in the core that needs a positive quantity. In a validate-style system it would defend itself:

-- Distrustful: the type is weak, so every routine re-checks.
reserve_stock(sku: String, qty: Integer)
  require
    positive: qty > 0        -- again? who was supposed to have checked this?
  do
    -- ...
  end

That precondition is a symptom, not a cure. It re-checks positivity because the type Integer does not carry it — and so the same require will appear on the next routine, and the next. In a parse-style system the routine takes the precise type instead, and the check simply vanishes:

-- Trustful: the type already carries the proof. Nothing to re-check.
reserve_stock(sku: Sku, qty: Quantity)
  do
    -- qty is positive because a Quantity cannot be anything else
  end

This is the payoff of parsing, and it is worth stating plainly: a precondition that re-checks something a type could carry is a sign the type is too weak. When you find the same require repeated across routines, the fix is usually not to keep writing it but to push it back to a parser and let a stronger type retire it. Contracts guard the relationships types cannot express (§3.5); they should not be spent re-establishing facts a type could have carried for free.

4.5 Parsers Compose

Real input is structured — a line item is a SKU and a quantity; an order is a list of line items — so parsers must combine. The rule of composition is that a compound parse succeeds only if every part succeeds, and reports the first failure otherwise. Done by hand, sequencing two parsers means matching the first result, and on its failure branch building a fresh Err to carry the reason up — the same rewrapping at every level. The standard library names that pattern once. result_map transforms the success value and leaves any failure untouched:

function parse_line_item(raw_sku: String, raw_qty: Integer): Result[Line_Item, String]
  do
    -- on success, build the Line_Item; any Err flows straight through
    result := result_map(
      parse_quantity(raw_qty),
      fn (q: Quantity): Line_Item do
        result := create Line_Item.make(raw_sku, q)
      end)
  end

Notice what did not have to be written: the failure branch. Because the error lives in its own type parameter, the Err from parse_quantity is already a Result[Line_Item, String]'s kind of failure — the same String channel — so result_map passes it through with no rebuilding. When the next step can itself fail, reach for result_and_then, whose function returns a Result and which short-circuits on the first Err; and when a lower layer's error type differs from the caller's, result_map_err translates the one channel into the other. Between them these three — map the value, chain a fallible step, translate the error — collapse the hand-written match-and-rewrap to a single expression.

Each parser converts one loose input into one precise value or a reason it could not; combining them yields a parser for the whole record with the same shape. Assemble line-item parsers the same way and you get parse_order, whose success case is a value the constrained core will accept without a second look — because by the time it exists, every constituent check has already been paid. This is the pipeline the next chapter builds on: once the boundary hands the core a trustworthy value, the domain logic is just a sequence of total functions over precise types.

4.6 The Boundary Is the Outer Face of the Membrane

Chapter 2 described contracts as the membrane between an open edge and a constrained core. Parsing is that membrane at the system's outer boundary — the face that meets the raw world. Untrusted input arrives; the parser is the one gate it must pass; and what emerges on the inside is a value the core's types and contracts already vouch for. The gate is where distrust is spent so that trust can be assumed everywhere behind it.

Placing the gate well is a design act, not a mechanical one. Put it too deep and untrusted values roam the interior, forcing the re-checking that parsing was meant to abolish. Put it right at the edge and the interior gets to be exactly what Chapter 1 promised: the part of the program you are least afraid of, because nothing untrustworthy can reach it. Every input crosses one gate, changes type once, and is believed thereafter.

We now have a constrained core (Chapter 3) and a boundary that feeds it only trustworthy values (Chapter 4). What remains is to make the core do something — to model a business process as a flow of these precise values through a sequence of reliable transformations. That is the next chapter: workflows as transformations.