Skip to content

Async Validation

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

Pudu

Async Validation

Some checks need to wait: a name registry, a remote allow-list. The Async module mirrors the sync API behind an explicit await boundary. Async.mustAsync takes an async predicate; Async.mustAsyncWithContext also receives immutable context data. Async.fromSync lifts an ordinary rule. Nothing async runs until Async.validateAsync is awaited:

module Ex07Async

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

type Signup = { name: Str, email: Str }

async fn nameFree(_root: Signup, name: Str) -> Bool { name != "taken" }

async fn emailKnown(_root: Signup, email: Str, _data: Rule.ContextData) -> Bool {
  email == "ada@example.com"
}

fn syncEmail() -> Rule.Rule[Signup] {
  Rule.build(Rule.must(Rule.ruleFor("email", |signup: Signup| signup.email),
    |email: Str| email != ""))
}

export async fn main() -> Int {
  let validator = Async.add(Async.add(Async.add(Async.create(),
    Async.mustAsync("name", |signup: Signup| signup.name, nameFree,
      "Name is taken.", "Taken")),
    Async.mustAsyncWithContext("email", |signup: Signup| signup.email, emailKnown,
      "Email is unknown.", "Unknown")),
    Async.fromSync(syncEmail()))
  let bad = Async.validateAsync(&validator, Signup{name: "taken", email: "ghost@example.com"}).await
  let chosen = Async.validateAsyncWith(&validator, Signup{name: "taken", email: "ghost@example.com"},
    Validator.includeProperties(Validator.defaults(), ["name"])).await
  let line = "bad=" + show(bad.errors.length()) + " chosen=" + show(chosen.errors.length())
  match Io.writeLine(line) {
    case Ok(_) => if bad.errors.length() == 2 && chosen.errors.length() == 1 { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

bad=2 chosen=1

Async validators keep the sync habits: named rule sets, property selection, child validators (setValidatorAsync, setOptionalValidatorAsync, forEachValidatorAsync), per-element collection checks, when/whenAsync conditions, severity and state overrides, and a prevalidation hook with sync (withPreValidation) and awaited (withPreValidationAsync) forms. Failures arrive in declaration order either way, so sync and async results read the same.

Filters, conditions, and custom async checks

Async.forEachAsync checks every array element; Async.forEachWhereAsync checks only the ones its filter keeps. Async.whenAsync awaits a context-aware condition before running its rule, and Async.customAsync lets an awaited callback report its own structured failures. Async.stopOnFirst ends the validator after its first failing rule, and Async.mustAsyncWithContext reads the same immutable data the sync validateWithData entry points supply:

module Ex14AsyncMore

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

type Item = { key: Str, tags: Array[Str] }

async fn rejectSkipAndShort(_root: Item, tag: Str) -> Bool { tag != "skip" && tag.length() >= 3 }

async fn included(tag: Str) -> Bool { tag != "skip" }

async fn keyExists(_root: Item, key: Str) -> Bool { key == "present" }

export async fn main() -> Int {
  let every = Async.forEachAsync("tags", |item: Item| item.tags, rejectSkipAndShort,
    "Tag is short.", "TagLength")
  let kept = Async.forEachWhereAsync("tags", |item: Item| item.tags, included, rejectSkipAndShort,
    "Tag is short.", "TagLength")
  let stocked = Item{key: "present", tags: ["skip", "x", "longer"]}
  let all = Async.validateAsync(&Async.add(Async.create(), every), stocked).await
  let some = Async.validateAsync(&Async.add(Async.create(), kept), stocked).await
  let conditional = Async.whenAsync(
    Async.mustAsync("key", |item: Item| item.key, keyExists, "The key was not found.", "MissingKey"),
    async fn(root: Item, _data: Rule.ContextData) -> Bool { !root.tags.isEmpty() })
  let skipped = Async.validateAsync(&Async.add(Async.create(), conditional),
    Item{key: "absent", tags: []}).await
  let ran = Async.validateAsync(&Async.add(Async.create(), conditional),
    Item{key: "absent", tags: ["x"]}).await
  let custom = Async.customAsync("root",
    async fn(root: Item, _data: Rule.ContextData) -> Array[Validation.Failure] {
      if root.key == "present" { [] } else { [Validation.Failure{
        propertyName: "root", errorMessage: "Root failed.", errorCode: "Root",
        severity: Validation.Error, customState: "" }] }
    })
  let customBad = Async.validateAsync(&Async.add(Async.create(), custom),
    Item{key: "absent", tags: []}).await
  let first = Async.mustAsync("key", |item: Item| item.key, keyExists, "Missing key.", "MissingKey")
  let second = Async.mustAsync("other", |item: Item| item.key, keyExists, "Missing other.", "MissingOther")
  let stopped = Async.validateAsync(&Async.stopOnFirst(Async.add(Async.add(Async.create(), first), second)),
    Item{key: "absent", tags: []}).await
  let flowing = Async.validateAsync(&Async.add(Async.add(Async.create(), first), second),
    Item{key: "absent", tags: []}).await
  let invited = Async.mustAsyncWithContext("key", |item: Item| item.key,
    async fn(_root: Item, key: Str, data: Rule.ContextData) -> Bool {
      match Map.get(&data, "expected") {
        case Some(Json.Text(wanted)) => key == wanted
        case _ => false
      }
    }, "Key is not invited.", "Invited")
  let strangers = Async.validateAsyncWithData(&Async.add(Async.create(), invited),
    Item{key: "ghost", tags: []}, Validator.defaults(),
    mapOf([("expected", Json.Text("present"))])).await
  let line = "every=" + show(all.errors.length()) + " kept=" + show(some.errors.length())
    + " skipped=" + show(skipped.errors.length()) + " ran=" + show(ran.errors.length())
    + " custom=" + show(customBad.errors[0].errorCode)
    + " stopped=" + show(stopped.errors.length()) + " flowing=" + show(flowing.errors.length())
    + " strangers=" + show(strangers.errors.length())
  match Io.writeLine(line) {
    case Ok(_) => if all.errors.length() == 2 && some.errors.length() == 1
      && skipped.errors.length() == 0 && ran.errors.length() == 1
      && customBad.errors[0].errorCode == "Root"
      && stopped.errors.length() == 1 && flowing.errors.length() == 2
      && strangers.errors.length() == 1 { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

every=2 kept=1 skipped=0 ran=1 custom="Root" stopped=1 flowing=2 strangers=1

Both "skip" and "x" fail the unfiltered check; the filter keeps only "x". The gated key check is skipped for the empty tag list and runs for the stocked one. The custom check names its own code, the stopped validator keeps one failure where the flowing one keeps two, and the stranger fails the invitation check from context data.

Context-aware collections and decorated rules

Async.forEachWhereAsyncWithContext passes the same immutable data to both its filter and its element predicate. Async.withSeverityFrom and Async.withStateFrom derive a failure's classification from the root source, and the static withSeverity and withState fix them:

module Ex21AsyncDecor

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

type Item = { key: Str, tags: Array[Str] }

export async fn main() -> Int {
  let tagged = Async.forEachWhereAsyncWithContext("tags", |item: Item| item.tags,
    async fn(tag: Str, data: Rule.ContextData) -> Bool {
      match Map.get(&data, "ignored") {
        case Some(Json.Text(skipped)) => tag != skipped
        case _ => true
      }
    },
    async fn(_root: Item, tag: Str, data: Rule.ContextData) -> Bool {
      match Map.get(&data, "expected") {
        case Some(Json.Text(wanted)) => tag == wanted
        case _ => false
      }
    }, "Tag differs from context.", "TagContext")
  let data: Rule.ContextData = mapOf([("expected", Json.Text("long")), ("ignored", Json.Text("skip"))])
  let stocked = Item{key: "present", tags: ["skip", "x", "long"]}
  let contextual = Async.validateAsyncWithData(&Async.add(Async.create(), tagged),
    stocked, Validator.defaults(), data).await
  let decorated = Async.withState(Async.withSeverityFrom(
    Async.mustAsync("key", |item: Item| item.key,
      async fn(_root: Item, key: Str) -> Bool { key == "present" },
      "The key was not found.", "MissingKey"),
    |root: Item| if root.key == "present" { Validation.Warning } else { Validation.Error }), "checked")
  let announced = Async.validateAsync(&Async.add(Async.create(), decorated),
    Item{key: "absent", tags: []}).await
  let failure = announced.errors[0]
  let line = "contextual=" + show(contextual.errors.map(|error: Validation.Failure| error.propertyName))
    + " severity=" + show(failure.severity) + " state=" + failure.customState
  match Io.writeLine(line) {
    case Ok(_) => if contextual.errors.length() == 1
      && contextual.errors[0].propertyName == "tags[1]"
      && failure.severity == Validation.Error && failure.customState == "checked" { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

contextual=["tags[1]"] severity=Error state=checked

The filter drops "skip" through context data and the predicate keeps only "long", so the single failure keeps its source index. The absent key fails with the computed Error severity and the fixed "checked" state.

Nested async children

Async.setValidatorAsync awaits one nested validator, Async.setOptionalValidatorAsync skips an absent one, and Async.forEachValidatorAsync awaits one per array element, each keeping its source index. A child built only from named rules stays silent in the default run; Async.inRuleSet files an async rule under a name and the static withSeverity fixes its classification:

module Ex24AsyncChild

import Std.Io as Io
import PuduLangValidator.Async as Async
import PuduLangValidator.Result as Validation
import PuduLangValidator.Validator as Validator

type Child = { code: Str }

type Root = { tag: Str, child: Child, items: Array[Child], note: Option[Child] }

async fn codeMatches(_root: Child, code: Str) -> Bool { code == "ok" }

fn childValidator() -> Async.AsyncValidator[Child] {
  Async.add(Async.create(), Async.mustAsync("code", |child: Child| child.code,
    codeMatches, "Code differs.", "Code"))
}

fn parentValidator() -> Async.AsyncValidator[Root] {
  let child = childValidator()
  let direct = Async.setValidatorAsync("child", |root: Root| root.child, child)
  let each = Async.forEachValidatorAsync("items", |root: Root| root.items, childValidator())
  let maybe = Async.setOptionalValidatorAsync("note", |root: Root| root.note, childValidator())
  let named = Async.inRuleSet(Async.withSeverity(
    Async.mustAsync("tag", |root: Root| root.tag,
      async fn(_root: Root, tag: Str) -> Bool { tag == "x" }, "Tag unknown.", "Tag"),
    Validation.Warning), "priority")
  Async.add(Async.add(Async.add(Async.add(Async.create(), direct), each), maybe), named)
}

export async fn main() -> Int {
  let validator = parentValidator()
  let root = Root{tag: "y", child: Child{code: "bad"},
    items: [Child{code: "ok"}, Child{code: "bad"}], note: None}
  let nested = Async.validateAsync(&validator, root).await
  let chosen = Async.validateAsyncWith(&validator, root, Validator.onlyRuleSets(["priority"])).await
  let line = "nested=" + show(nested.errors.map(|failure: Validation.Failure| failure.propertyName))
    + " priority=" + show(chosen.errors[0].severity)
  match Io.writeLine(line) {
    case Ok(_) => if nested.errors.length() == 2
      && nested.errors[0].propertyName == "child.code"
      && nested.errors[1].propertyName == "items[1].code"
      && chosen.errors.length() == 1 && chosen.errors[0].severity == Validation.Warning { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

nested=["child.code", "items[1].code"] priority=Warning

The direct child and the second item fail with prefixed paths while the absent note stays silent and the named tag rule waits. Under the priority set only the tag rule runs, failing with its fixed Warning severity.

Awaited gates before any rule

Async.withPreValidation installs a synchronous root callback and Async.withPreValidationAsync an awaited one that also reads context data; either answers None to continue or Some(failures) to return immediately. Async.when runs its rule only while its sync condition holds:

module Ex25AsyncPreflight

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

type Item = { key: Str, enabled: Bool }

async fn keyExists(_root: Item, key: Str) -> Bool { key == "present" }

fn blocked() -> Array[Validation.Failure] {
  [Validation.Failure{propertyName: "", errorMessage: "Blocked.",
    errorCode: "Blocked", severity: Validation.Error, customState: ""}]
}

export async fn main() -> Int {
  let check = Async.mustAsync("key", |item: Item| item.key, keyExists,
    "The key was not found.", "MissingKey")
  let syncGate = Async.withPreValidation(Async.add(Async.create(), check),
    fn(item: Item) -> Option[Array[Validation.Failure]] {
      if item.key == "Blocked" { Some(blocked()) } else { None }
    })
  let syncBlocked = Async.validateAsync(&syncGate, Item{key: "Blocked", enabled: true}).await
  let syncPlain = Async.validateAsync(&syncGate, Item{key: "absent", enabled: true}).await
  let asyncGate = Async.withPreValidationAsync(Async.add(Async.create(), check),
    async fn(item: Item, data: Rule.ContextData) -> Option[Array[Validation.Failure]] {
      match Map.get(&data, "expected") {
        case Some(Json.Text(wanted)) => if item.key == wanted { None } else {
            Some([Validation.Failure{propertyName: "key", errorMessage: "Unexpected key.",
              errorCode: "Unexpected", severity: Validation.Error, customState: ""}]) }
        case _ => None
      }
    })
  let data: Rule.ContextData = mapOf([("expected", Json.Text("present"))])
  let asyncEarly = Async.validateAsyncWithData(&asyncGate,
    Item{key: "ghost", enabled: true}, Validator.defaults(), data).await
  let asyncPass = Async.validateAsyncWithData(&asyncGate,
    Item{key: "present", enabled: true}, Validator.defaults(), data).await
  let gated = Async.when(check, |item: Item| item.enabled)
  let gatedSkip = Async.validateAsync(&Async.add(Async.create(), gated),
    Item{key: "absent", enabled: false}).await
  let gatedRun = Async.validateAsync(&Async.add(Async.create(), gated),
    Item{key: "absent", enabled: true}).await
  let line = "sync=" + show(syncBlocked.errors[0].errorCode) + "/" + show(syncPlain.errors[0].errorCode)
    + " async=" + show(asyncEarly.errors[0].errorCode) + "/" + show(asyncPass.isValid)
    + " gated=" + show(gatedSkip.isValid) + "/" + show(gatedRun.isValid)
  match Io.writeLine(line) {
    case Ok(_) => if syncBlocked.errors[0].errorCode == "Blocked"
      && syncPlain.errors[0].errorCode == "MissingKey"
      && asyncEarly.errors[0].errorCode == "Unexpected" && asyncPass.isValid
      && gatedSkip.isValid && !gatedRun.isValid { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

sync="Blocked"/"MissingKey" async="Unexpected"/true gated=true/false

The blocked key is refused before any rule runs while the plain miss falls through to the key check. The awaited gate refuses the stranger from context data and passes the invited key on to its rule. The gated check is skipped while disabled and fails while enabled.

Related

Clone this wiki locally