Skip to content

Rules And Validators

Chris Michael edited this page Sep 29, 2026 · 4 revisions

Pudu

Rules And Validators

Three functions build most validators. Rule.ruleFor names a property and selects it. Rule.must adds a predicate over the selected value. Rule.build freezes the rule so a validator can hold it:

module Ex02Rules

import Std.Io as Io
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator

type Customer = { name: Str, age: Int }

fn startsUpper(value: Str) -> Bool {
  if value.isEmpty() {
    false
  } else {
    let first = value.chars()[0]
    first >= 'A' && first <= 'Z'
  }
}

fn customerValidator() -> Validator.Validator[Customer] {
  let name = Rule.build(Rule.withMessage(
    Rule.must(Rule.ruleFor("name", |customer: Customer| customer.name), startsUpper),
    "Name must start with a capital letter."))
  let adult = Rule.build(Rule.mustWith(Rule.ruleFor("age", |customer: Customer| customer.age),
    fn(customer: Customer, age: Int) -> Bool { age >= 18 || customer.name == "Junior" }))
  Validator.add(Validator.add(Validator.create(), name), adult)
}

fn seniorValidator() -> Validator.Validator[Customer] {
  Validator.add(Validator.create(), Rule.build(Rule.withMessage(
    Rule.must(Rule.ruleFor("age", |customer: Customer| customer.age), |age: Int| age <= 120),
    "Customers top out at 120.")))
}

export fn main() -> Int {
  let validator = customerValidator()
  let bad = Validator.validate(&validator, Customer{name: "ada", age: 16})
  let junior = Validator.validate(&validator, Customer{name: "Junior", age: 16})
  let combined = Validator.include(seniorValidator(), &validator)
  let ancient = Validator.validate(&combined, Customer{name: "ada", age: 200})
  let line = "bad=" + show(bad.errors.length()) + " juniorValid=" + show(junior.isValid)
    + " included=" + show(ancient.errors.map(|failure| failure.propertyName))
  match Io.writeLine(line) {
    case Ok(_) => if bad.errors.length() == 2 && junior.isValid
      && ancient.errors.length() == 2
      && ancient.errors[0].propertyName == "age"
      && ancient.errors[1].propertyName == "name" { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

bad=2 juniorValid=true included=["age", "name"]

Two details matter here. Rule.mustWith receives the whole record alongside the selected value, so one property's check can read another. Rule.withMessage replaces the default message for the latest check only; anything you do not touch keeps its built-in text and code.

Validators compose. Validator.include appends another validator's rules in order — the seniority rule runs first because its validator was included first, so the ancient failure reads ["age", "name"] — and Validator.stopOnFirst stops a validator after the first rule that fails. Rules are immutable values, so one validator can be reused across many inputs safely.

Checks that read context, and metadata they compute

Rule.mustWithContext receives the immutable context data alongside the root and the selected value; Validator.validateWithData supplies it. The withMessageFrom, withSeverityFrom, and withStateFrom decorators compute a check's message, severity, and state from the same inputs, so one check can warn for one record and error for another. Rule.stopOnFirst ends a rule's own chain after its first failure, and Rule.whenCurrent guards only the latest check while Rule.when guards them all:

module Ex10Context

import Std.Io as Io
import Std.Json as Json
import Std.Map as Map
import PuduLangValidator.Result as Validation
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator

type Signup = { name: Str, age: Int }

fn contextValidator() -> Validator.Validator[Signup] {
  let invited = Rule.build(Rule.withStateFrom(Rule.withSeverityFrom(Rule.withMessageFrom(
    Rule.mustWithContext(Rule.ruleFor("name", |signup: Signup| signup.name),
      fn(_root: Signup, name: Str, data: Rule.ContextData) -> Bool {
        match Map.get(&data, "expected") {
          case Some(Json.Text(wanted)) => name == wanted
          case _ => false
        }
      }),
    |_root: Signup, name: Str| "Only invited guests may join, not " + name + "."),
    |root: Signup, _name: Str| if root.age >= 18 { Validation.Warning } else { Validation.Error }),
    |_root: Signup, name: Str| "saw:" + name))
  let short = Rule.build(Rule.stopOnFirst(Rule.must(
    Rule.must(Rule.ruleFor("name", |signup: Signup| signup.name), |name: Str| name.length() >= 2),
    |name: Str| name != "admin")))
  Validator.add(Validator.add(Validator.create(), invited), short)
}

export fn main() -> Int {
  let validator = contextValidator()
  let data: Rule.ContextData = mapOf([("expected", Json.Text("Ada"))])
  let guest = Validator.validateWithData(&validator, Signup{name: "Bo", age: 20}, Validator.defaults(), data)
  let failure = guest.errors[0]
  let empty = Validator.validate(&validator, Signup{name: "", age: 30})
  let latest = Rule.build(Rule.whenCurrent(
    Rule.must(Rule.must(Rule.ruleFor("name", |signup: Signup| signup.name), |name: Str| name.length() >= 2),
      |name: Str| !name.isEmpty()), |signup: Signup| signup.age >= 18))
  let latestValidator = Validator.add(Validator.create(), latest)
  let allChecks = Validator.validate(&latestValidator, Signup{name: "", age: 20})
  let currentChecks = Validator.validate(&latestValidator, Signup{name: "", age: 3})
  let line = "message=" + failure.errorMessage + " severity=" + show(failure.severity)
    + " state=" + failure.customState + " emptyShort=" + show(empty.errors.length())
    + " all=" + show(allChecks.errors.length()) + " current=" + show(currentChecks.errors.length())
  match Io.writeLine(line) {
    case Ok(_) => if guest.errors.length() == 1 && failure.severity == Validation.Warning
      && failure.customState == "saw:Bo" && empty.errors.length() == 2
      && allChecks.errors.length() == 2 && currentChecks.errors.length() == 1 { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

message=Only invited guests may join, not Bo. severity=Warning state=saw:Bo emptyShort=2 all=2 current=1

"Bo" is not the invited "Ada", so the context check fails with its computed message, a Warning for an adult, and the observed name as state. An empty name fails the retry rule's single check plus the context check. The whenCurrent rule runs both of its checks for the adult (two failures) but only the first for the child (one failure). Rule.withName renames a builder for messages and Rule.overridePropertyName re-roots its failures; collection indexing uses the latter internally.

Related

Clone this wiki locally