Guide 22 of 53
Validation, schemas and units
Validated values, typed captures, schemas and dimensional units.
On this page
Validated values, typed captures, schemas and dimensional units.
Validated scalar newtypes¶
A newtype with where makes its predicate part of construction. Its new constructor returns Result; inspect failure or propagate it. Derived operations retain validation. An ordinary newtype without where keeps its direct constructor.
Practice in By Example · Source program
// Validation is part of construction: .new returns Result<Score, str>.
newtype Score(int) deriving Eq, Ord, Clone, Default, Add where |v| 0 <= v <= 100
newtype Positive(int) where |v| v > 0
newtype UserId(int)
let score = Score.new(40).unwrap()
let delta = Score.new(2).unwrap()
print(score.unwrap()) // expected: 40
print((score + delta).unwrap()) // expected: 42
print(score.clone() == score) // expected: true
print(Score.default().unwrap()) // expected: 0
print(Score.new(101).is_err()) // expected: true
print(Positive.new(0).is_err()) // expected: true
// Ordinary newtypes still construct directly.
print(UserId.new(7).unwrap()) // expected: 7Declared unit suffixes¶
Attach @unit to a scalar newtype to give literals a typed suffix. The suffix preserves the declared nominal owner and scalar precision. A suffix alone does not define a conversion scale: declare dimensions and scales when automatic conversion is needed.
Practice in By Example · Source program
// Declared suffixes retain exact scalar spelling and nominal ownership.
@unit("kg") newtype Mass(decimal) where |value| value >= 0d
@unit("g") newtype Grams(decimal)
@unit("crate") newtype Crates(int)
let parcel = 2.5kg
let total = parcel + 0.5kg
print(total.unwrap()) // expected: 3.0
print((parcel * 2d).unwrap()) // expected: 5.0
print(parcel / 0.5kg) // expected: 5
print(parcel > 1kg) // expected: true
let grams = 500g
let converted = Mass.new(grams.unwrap() / 1000d).unwrap()
print(converted.unwrap()) // expected: 0.50
let stock = 8crate
print((stock / 2).unwrap()) // expected: 4
print(stock / 2crate) // expected: 4Typed string captures¶
A pat literal matches the entire string and converts its captures before exposing bindings. A failed scalar conversion or validated constructor is a non-match. Matching uses the first delimiter without backtracking; adjacent captures without a delimiter are rejected.
Practice in By Example · Source program
// Complete-string matching; conversions and guards can fall through.
fn order(input: str) -> str {
match input {
pat"order/{id: int}/{status: str}" if id > 0 => "{id}:{status}",
_ => "invalid",
}
}
print(order("order/42/paid")) // expected: 42:paid
print(order("order/oops/paid")) // expected: invalid
print(order("prefixorder/7/paid")) // expected: invalid
print(order("order/-1/paid")) // expected: invalid
let enabled = match "flag/true" {
pat"flag/{value: bool}" => value,
_ => false,
}
print(enabled) // expected: true
let braces = match r"{done}/7" {
pat"{{done}}/{value: int}" => value,
_ => -1,
}
print(braces) // expected: 7
newtype Positive(int) where |value| value > 0
let checked = match "id/23" {
pat"id/{value: Positive}" => value.unwrap(),
_ => -1,
}
print(checked) // expected: 23
let rejected = match "id/-2" {
pat"id/{value: Positive}" => value.unwrap(),
_ => -1,
}
print(rejected) // expected: -1Validated nominal string literals¶
A literal such as Email"ana@empresa.com" is admitted only when the real validated string newtype's predicate accepts its static contents at compile time. Dynamic strings still use the fallible constructor. This syntax also preserves existing callable tagged templates.
Practice in By Example · Source program
// expect: ana@empresa.com
// expect: true
// expect: empty
// expect: alias@empresa.com
// expect: callable
const required = "@"
fn valid_email(value: str) -> bool = value.contains(required)
newtype Email(str) where |value| valid_email(value)
newtype Empty(str) where |value| value == ""
let email = Email"ana@empresa.com"
print(email.unwrap())
let input = "invalid"
print(Email.new(input).is_err())
let empty = Empty""
print(if empty.unwrap() == "" { "empty" } else { "wrong" })
mod address { pub newtype Address(str) where |value| value.contains("@") }
use address::{Address as Mail}
print(Mail"alias@empresa.com".unwrap())
fn callable() -> str { let Email = |parts: [str]| parts[0]; Email"callable" }
print(callable())First-class capture patterns¶
Store a pat literal as a reusable value. Its parse method returns Result with an exact typed capture record. Input mismatch and failed conversions return structured errors. Construction retains conversion functions without running their validation predicates.
Practice in By Example · Source program
// Exact public capture record; construction performs no parse or validation.
var input_calls = 0
fn input() -> str { input_calls += 1; "users/9223372036854775807/false" }
let route = pat"users/{id: int}/{ready: bool}"
let captured = route.parse(input()).unwrap()
print(captured.id)
print(captured.ready)
print(input_calls)
print(route.parse("prefix/users/1/true").is_err())
print(route.parse("users/9223372036854775808/true").is_err())
print(route.parse("users/1/true/trailing").is_err())
match route.parse("users/nope/true") {
.Err(error) => { print(error.kind); print(error.input) },
.Ok(_) => print("unexpected"),
}
let id = "outer"
let empty = pat""
print(empty.parse("").is_ok())
print(empty.parse("x").is_err())
let escaped = pat"{{literal}}/{id: str}"
print(escaped.parse(r"{literal}/é中😀").unwrap().id)
print(id)
newtype Positive(int) where |n| n > 0
let positive = pat"n/{value: Positive}"
print(positive.parse("n/3").unwrap().value.unwrap())
print(positive.parse("n/-1").is_err())
var cleanup = ""
struct ScopeResource {}
impl core::cleanup::Cleanup for ScopeResource { fn close(self) { cleanup += "closed" } }
using resource = ScopeResource {} {
let parsed = route.parse("bad")
defer { cleanup += "defer/" }
print(parsed.is_err())
}
print(cleanup)
fn parse_with_cleanup() -> Result<int, { kind: str, input: str, pattern: str }> {
using resource = ScopeResource {} {
defer { cleanup += "defer/" }
let args = route.parse("bad")?;
.Ok(args.id)
}
}
cleanup = ""
print(parse_with_cleanup().is_err())
print(cleanup)
var construction_calls = 0
fn make_route() { construction_calls += 1; pat"users/{id: int}" }
print(make_route().parse("users/8").unwrap().id)
print(construction_calls)Cross-field schema invariants¶
Attach where predicates after a schema declaration to validate the assembled value. Field conversion and defaults happen first. Invariants return bool and run in order; failure becomes a structured DecodeError at the object path. Field constraints keep their own field scope.
Practice in By Example · Source program
// Item 13: field checks -> defaults -> assembled-object invariants, once each.
var trace = ""
var cleaned = 0
fn last_default() -> int { trace += "D"; 10 }
fn nonnegative(value: int) -> bool { trace += "F"; value >= 0 }
schema Booking {
start: int where nonnegative,
end: int = last_default(),
}
where |booking| {
defer { cleaned += 1 }
trace += "I"
booking.start < booking.end
}
where |booking| { trace += "J"; booking.end <= 20 }
fn show(result: Result<Booking, DecodeError>) {
match result {
.Ok(booking) => print("ok:{booking.start}:{booking.end}"),
.Err(error) => {
print("{error.path}:{error.kind}:{error.expected}:{error.actual}:{error.message}")
print(error.causes.len())
},
}
}
fn main() {
let valid = Booking.parse(#{start: 2})
assert(match valid { .Ok(value) => value.start == 2 && value.end == 10, .Err(_) => false })
assert(trace == "FDIJ")
show(valid)
print(trace)
trace = ""
let invariant_failure = Booking.parse(#{start: 10, end: 1})
assert(match invariant_failure {
.Err(error) => error.path == "$" && error.expected == "Booking" && error.actual == "Booking"
&& error.message == "Booking: invariant 1 failed" && error.causes.len() == 0,
.Ok(_) => false,
})
assert(trace == "FI")
show(invariant_failure)
print(trace)
trace = ""
let field_failure = Booking.parse(#{start: -1})
assert(match field_failure {
.Err(error) => error.path == "$.start" && error.message == "start: constraint failed",
.Ok(_) => false,
})
assert(trace == "F")
show(field_failure)
print(trace)
trace = ""
let wrong_type = Booking.parse(#{start: 2, end: "bad"})
assert(match wrong_type {
.Err(error) => error.path == "$.end" && error.expected == "int" && error.actual == "string",
.Ok(_) => false,
})
assert(trace == "F")
show(wrong_type)
print(trace)
print(cleaned)
assert(cleaned == 2)
}Dimensions and unit scales¶
Declare base units, derived dimensions and exact scales with @unit. Multiplication and division derive dimensions; as converts only compatible dimensions and checks representability. Duration suffixes retain ordinary millisecond behavior outside a dimensional context.
Practice in By Example · Source program
// Exact dimensional scales; standalone durations retain milliseconds.
@unit("m", base: true) newtype Meters(decimal) where |v| v >= 0d
@unit("km", of: Meters, scale: 1000d) newtype Kilometers(decimal)
@unit("s", base: true, duration: true) newtype Seconds(decimal)
@unit("h", of: Seconds, scale: 3600d, duration: true) newtype Hours(decimal)
@unit("kph", dimension: Kilometers / Hours) newtype Kph(decimal)
@unit("kmh", dimension: Kilometers * Hours) newtype Kmh(decimal)
fn main() {
let distance = 2km as Meters
assert(distance.unwrap() == 2000d)
print(distance.unwrap())
let speed = 80km / 2h
let displayed = speed as Kph
assert(displayed.unwrap() == 40d)
print(displayed.unwrap())
let covered = (speed * 2h) as Kilometers
assert(covered.unwrap() == 80d)
print(covered.unwrap())
let reverse = (2h * 80km) as Kmh
assert(reverse.unwrap() == 160d)
print(reverse.unwrap())
assert(((2km / 500m) as decimal) == 4d)
print((2km / 500m) as decimal)
let wait = 2h
assert(wait == 7_200_000)
print(wait)
let hours: Hours = 2h
assert(hours.unwrap() == 2d)
print(hours.unwrap())
let nano: Seconds = 1ns
assert(nano.unwrap() == 0.000000001d)
print(nano.unwrap())
assert(1ns == 0)
print(1ns)
let clock = 90_061_000
print("{clock as h}:{clock as min}:{clock as s}:{clock as ms}")
}
main()Continue learning¶
DOCS / FEEDBACK
Did this page leave a question?
Tell us where the explanation lost you. Documentation is part of the language experience.