Skip to content

Typed Settings

Chris Michael edited this page Oct 7, 2026 · 1 revision

Pudu

Typed Settings

A program's settings are a record. derives Binding.Bind reads each field from a variable; derives Binding.Redacted renders the record with its secrets hidden; Settings.bind reads it and runs a pudu-lang-validator validator over it before the program starts:

module Ex08Settings

import Std.App.Secret as Secret
import Std.Io as Io
import Std.Map as Map
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Rules.Number as NumberRule
import PuduLangValidator.Rules.Text as TextRule
import PuduLangValidator.Validator as Validator
import PuduLangEnvironment as Environment
import PuduLangEnvironment.Binding as Binding
import PuduLangEnvironment.Loader as Loader
import PuduLangEnvironment.Options as Options
import PuduLangEnvironment.Settings as Settings
import PuduLangEnvironment.Source as Source

type Server = {
  port: Int,
  publicUrl: Str,
  @env("DB_URL") @secret database: Secret.Secret,
  @default("info") logLevel: Str,
  workers: Option[Int]
} derives Binding.Bind, Binding.Redacted

fn rules() -> Validator.Validator[Server] {
  let port = Rule.build(NumberRule.inclusiveBetween(Rule.ruleFor("port", |given: Server| given.port), 1024, 65535))
  let url = Rule.build(TextRule.notEmpty(Rule.ruleFor("publicUrl", |given: Server| given.publicUrl)))
  Validator.add(Validator.add(Validator.create(), port), url)
}

fn attempt(text: Str) -> () {
  let options = Options.defaults().withSources(&[Source.Text("example", text)])
  let line = match Loader.loadOver(&options, &Map.empty()) {
    case Ok(variables) => match Settings.bind(&variables, &rules()) {
      case Ok(server) => server.redacted()
      case Err(problem) => Environment.describe(&problem)
    }
    case Err(problem) => Environment.describe(&problem)
  }
  let _shown = Io.writeLine(line)
}

export fn main() -> Int {
  attempt("PORT=8443\nPUBLIC_URL=https://api.example.org\nDB_URL=postgres://app:s3cret@db/app\nWORKERS=4\n")
  attempt("PORT=8443\n")
  attempt("PORT=eighty\nPUBLIC_URL=x\nDB_URL=y\n")
  attempt("PORT=80\nPUBLIC_URL=https://api.example.org\nDB_URL=postgres://app:s3cret@db/app\n")
  0
}

Output:

Server{port: 8443, publicUrl: "https://api.example.org", database: [REDACTED], logLevel: "info", workers: Some(4)}
No value is set for: PUBLIC_URL, DB_URL
The value of 'PORT' is not a whole number.
The settings are invalid: port (InclusiveBetween)

Field attributes

Attribute Effect
none the field reads its name in upper snake case: publicUrl reads PUBLIC_URL
@env("NAME") the field reads NAME
@default("text") an unset variable reads text instead
@secret redacted() renders the field as [REDACTED], whatever its type

A Secret.Secret field renders as [REDACTED] with or without @secret, and so does one inside an Option; the attribute is for fields of other types, such as a password kept as Str. Plain show over a record prints a secret's raw value, so render settings with redacted().

A field may be Str, Int, Float64, Decimal, Bool, Secret.Secret, or an Option of one of them; an Option field is None when its variable is unset. Implement Binding.Variable for a type of your own to read it too.

bind reports every unset required variable at once as Missing, then the first value that does not convert as Malformed. A validator's failures of Error severity refuse the record as Invalid, listing each property and the validator's code but not its message, since a message may quote {PropertyValue}. Warnings never stop a program from starting. Settings.check runs only the validator, for a record built some other way.

Related

Clone this wiki locally