Skip to content

Messages And Metadata

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

Pudu

Messages And Metadata

A failure is more than text. Rule.withMessage sets fixed text; Rule.mustWithArguments captures named values while it decides, and the message renders them through {Name} placeholders. Built-in checks already provide their bounds and measured values this way. Localization.localized picks the message from a catalog by the record's own locale:

module Ex06Messages

import Std.Io as Io
import PuduLangValidator.Localization as Localization
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Rules.Number as NumberRule
import PuduLangValidator.Validator as Validator

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

fn catalog() -> Localization.Catalog {
  Localization.withMessage(Localization.withMessage(Localization.empty(),
    "en", "AgeCheck", "You must be 18 or older."), "es", "AgeCheck", "Debes tener 18 años o más.")
}

fn ageValidator() -> Validator.Validator[Customer] {
  let age = Rule.build(Localization.localized(
    NumberRule.greaterThanOrEqualTo(Rule.ruleFor("age", |customer: Customer| customer.age), 18),
    catalog(), |customer: Customer| customer.locale, "AgeCheck", "You must be 18 or older."))
  Validator.add(Validator.create(), age)
}

fn arguedValidator() -> Validator.Validator[Customer] {
  let age = Rule.build(Rule.withCode(Rule.withMessage(Rule.mustWithArguments(
    Rule.ruleFor("age", |customer: Customer| customer.age),
    fn(_customer: Customer, age: Int) -> Option[Array[(Str, Str)]] {
      if age >= 18 { None } else { Some([("Minimum", "18")]) }
    }), "Too young: need \{Minimum\} years."), "AgeCheck"))
  Validator.add(Validator.create(), age)
}

export fn main() -> Int {
  let validator = ageValidator()
  let spanish = Validator.validate(&validator, Customer{name: "Bo", age: 3, locale: "es"})
  let argued = Validator.validate(&arguedValidator(), Customer{name: "Bo", age: 3, locale: "en"})
  let line = "es=" + spanish.errors[0].errorMessage + " | argued=" + argued.errors[0].errorMessage
  match Io.writeLine(line) {
    case Ok(_) => if spanish.errors[0].errorMessage == "Debes tener 18 años o más."
      && argued.errors[0].errorMessage == "Too young: need 18 years." { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

es=Debes tener 18 años o más. | argued=Too young: need 18 years.

Every failure also carries a stable errorCode, a severity, and a customState string for application use. Rule.withSeverity and Rule.withState fix them; Rule.withSeverityFrom and Rule.withStateFrom compute them from the record and the selected value, so one rule can warn for small problems and error for large ones. Three placeholders are always available: {PropertyName}, {PropertyPath}, and {PropertyValue}.

Looking a message up directly

Localization.messageFor reads one entry from a catalog without validating anything: the record's locale, a failure code, and a fallback for a missing entry. Locales match case-insensitively, so "ES" finds the "es" entry:

module Ex16Catalog

import Std.Io as Io
import PuduLangValidator.Localization as Localization

fn catalog() -> Localization.Catalog {
  Localization.withMessage(Localization.withMessage(Localization.empty(),
    "en", "AgeCheck", "You must be 18 or older."), "es", "AgeCheck", "Debes tener 18 años o más.")
}

export fn main() -> Int {
  let texts = catalog()
  let spanish = Localization.messageFor(&texts, "es", "AgeCheck", "You must be 18 or older.")
  let missing = Localization.messageFor(&texts, "fr", "AgeCheck", "You must be 18 or older.")
  let line = "es=" + spanish + " | fr=" + missing
  match Io.writeLine(line) {
    case Ok(_) => if spanish == "Debes tener 18 años o más." && missing == "You must be 18 or older." { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

es=Debes tener 18 años o más. | fr=You must be 18 or older.

Spanish resolves from the catalog; French has no entry, so the caller's fallback answers. The localized rule above uses the same lookup with the record's own locale on every failure.

Arguments a check captures itself

Rule.mustWithArguments captures named values while it decides; Rule.withArgument fixes one and Rule.withArgumentFrom computes one from the record and the value, and the message renders them through {Name} placeholders. Rule.withName renames a builder for {PropertyName}, and Rule.overridePropertyName re-roots a rule's failures at another path:

module Ex18Args

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

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

fn argsValidator() -> Validator.Validator[Customer] {
  let age = Rule.build(Rule.withArgument(Rule.withArgumentFrom(
    Rule.withMessage(Rule.mustWithArguments(Rule.ruleFor("age", |customer: Customer| customer.age),
        fn(_root: Customer, age: Int) -> Option[Array[(Str, Str)]] {
          if age >= 18 { None } else { Some([("Minimum", "18")]) }
        }), "Too young: need \{Minimum\} \{Unit\}, have \{Actual\}."),
    "Actual", fn(_root: Customer, age: Int) -> Str { show(age) }), "Unit", "years"))
  let named = Rule.build(Rule.withMessage(
    Rule.must(Rule.withName(Rule.ruleFor("name", |customer: Customer| customer.name),
      "Display"), |name: Str| !name.isEmpty()), "Bad \{PropertyName\}."))
  let aliased = Rule.build(Rule.must(
    Rule.overridePropertyName(Rule.ruleFor("name", |customer: Customer| customer.name), "alias"),
    |name: Str| !name.isEmpty()))
  let flagged = Rule.build(Rule.withState(Rule.withSeverity(Rule.withCode(Rule.withMessage(
    Rule.must(Rule.ruleFor("name", |customer: Customer| customer.name), |name: Str| name != "banned"),
    "That name is retired."), "Retired"), Validation.Warning), "legacy"))
  Validator.add(Validator.add(Validator.add(Validator.add(Validator.create(), age), named), aliased), flagged)
}

export fn main() -> Int {
  let result = Validator.validate(&argsValidator(), Customer{name: "", age: 3})
  let retired = Validator.validate(&argsValidator(), Customer{name: "banned", age: 30})
  let flag = retired.errors[0]
  let line = "young=" + result.errors[0].errorMessage
    + " shown=" + result.errors[1].errorMessage
    + " alias=" + result.errors[2].propertyName
    + " retired=" + show(flag.severity) + "/" + flag.customState
  match Io.writeLine(line) {
    case Ok(_) => if result.errors[0].errorMessage == "Too young: need 18 years, have 3."
      && result.errors[1].errorMessage == "Bad Display."
      && result.errors[2].propertyName == "alias"
      && retired.errors.length() == 1 && flag.severity == Validation.Warning
      && flag.customState == "legacy" { 0 } else { 2 }
    case Err(_) => 1
  }
}

Output:

young=Too young: need 18 years, have 3. shown=Bad Display. alias=alias retired=Warning/legacy

The age check captures Minimum itself while Actual is computed and Unit is fixed, so one message carries all three. The renamed rule renders its display name, and the re-rooted rule reports alias instead of name. The static withSeverity and withState fix the retired name's classification outright: Warning severity with "legacy" state, no computation.

Related

Clone this wiki locally