Validators
Run in Rust; TypeScript not yet
comline build verifies validators: an unknown validator name, a validator
put on a value of a type it doesn't apply to, a keyword argument that isn't
a property or whose literal type is wrong, a missing argument for a property
without a default, or an undeclared params.* reference all fail the build. Generated Rust code then
runs them. Generated TypeScript doesn't yet: it
generates the types without the checks, and comline generate warns. See
TypeScript's language reference.
A validator is a named, parameterised check you attach to a
struct or error field, or to a
type alias.
Target types — for
Every validator names the type(s) it checks, after for. A validator without
for is a build error.
- A field can take a validator only if its type is one of the targets, matched
exactly: a
u8field doesn't take afor u32validator. - A
typealias counts as the type it stands for, so a field of typetype Name = stringtakes afor stringvalidator. - An
optionalfield is checked as its inner type. - On an array field, a validator checks the array, not each element: a
for stringvalidator doesn't go on astring[]field. - Targets are primitives, arrays of them, or aliases. There are no generic
targets; list each type instead, with one exception:
any[]. - Bounds are ignored when matching.
for stringapplies to every string, whatever its maximum (char[max 32],char[8]), andfor u32[]to everyu32[max N]. any[]is a dynamic array of any element type, so one validator can check a list's length without naming what it holds.anyis a keyword only as an array's element in afor:any[][]is a list of lists, and a bareanyis an error.any[]doesn't match a string, which is why std'sNonEmptylists both:for string, any[].
Properties
name: Type [= default], one per line — the same shape as a
struct field, minus optional and annotations. They are
the validator's configuration, supplied where the validator is used.
validate
A validate block holds one or more assert(condition, message) calls.
condition— member paths overvalue.*(the field under check) andparams.*(this validator's properties), compared with== != >= <= > <and joined byand/or.assert(c, m)passes whencis true. Numbers compare by value, so au8value against au32property is fine.valueaccessors:valueitself;value.length, only when every target isstringor an array. A string's length counts characters (Unicode scalar values), not bytes, in every language; an array's counts elements.value.name, where the value sits, for messages:handle,friends[1],author.handle.
Anything else is a build error at the validator.
- message — a string, or an f-string whose
{value.…} and {params.…} placeholders are substituted. A plain "..."
is printed as written.
Three mistakes build, but warn:
| Warning | When |
|---|---|
validation::validator-checks-nothing |
the validator has no validate block, or an empty one; shown at the block when there is one |
validation::validator-property-unused |
a property no assert or message reads |
validation::message-not-interpolated |
a plain "..." message with a {...} in it, which would print as written; the editor's quick fix makes it an f-string |
The condition language is deliberately small and non-computational — no precedence, no loops, no bindings.
Using one — @validators
Attach validators to a field with the @validators annotation: a list of calls
that bind the validator's properties by name.
Unbound properties fall back to their declared default; leaving out one without
a default is a build error. A validator with nothing to bind can be written
bare: @validators = [NonEmpty] is the same as [NonEmpty()].
Errors' fields take @validators too.
A validator from another schema needs a use, like a type does (such as
use std::validators::StringBounds). Without one it's unknown, and the editor
offers to add the use. An imported validator is checked the same way as a
local one: its for targets and its arguments, against its declaration.
@validators goes on a field or a type alias. On a struct, error, protocol or
function there's no single value for it to check, so it's a build error.
The standard validators
std::validators ships these. A maximum belongs
in the type (char[max 32]), where a decoder checks it before
allocating; validators check what a type can't say, a minimum or a range.
| Validator | For | Checks |
|---|---|---|
NonEmpty |
string, any[] |
not empty |
MinChars(min) |
string |
at least min characters |
MinItems(min) |
any[] |
at least min items |
IntRange(min, max) |
u8 u16 u32 u64 s8 s16 s32 s64 |
the value is within min and max, both included; each defaults to the type's widest bound |
StringBounds(min_chars, max_chars) |
string |
the length is within both; max_chars defaults to no limit. Prefer the type's maximum and MinChars |
A std validator's size is checked against the type's maximum at build. A
minimum above it refuses every value, so it is an error: MinChars(min = 40)
on a char[max 32] (bounds::validator-minimum-above-maximum). A cap below it
refuses values the type allows, so it is a warning that suggests moving the cap
into the type (bounds::validator-caps-below-type).
On a type alias
Put @validators on a type alias to check every value of that alias,
wherever it's used:
flowchart LR
A["Handle's validators<br/>StringBounds(2, 8)"] --> F["the field's own<br/>@validators"]
F --> OK["value accepted"]
A value of the alias is checked by the alias's validators first (an alias of an alias: innermost first), then by the field's own.
- The alias stays the type it stands for:
Handleis still aStringin generated Rust. Adding a validator never changes a type's API or its wire format. - The validator's
fortarget is checked once, against what the alias resolves to. - An alias of an alias collects the checks along the chain.
- A list of the alias (
Handles, above) checks each element. - An alias is the only way to check a function argument: arguments take no
annotations of their own, but
to: HandlerunsHandle's checks.
@deprecated works on an alias too, and warns wherever the alias is used.
In generated code
In Rust, every struct, error and function-params struct with something to
check implements comline_runtime::validation::Validate. That includes checks
on its own fields, through an alias, or further down in a nested struct or an
array element.
use comline_runtime::validation::Validate;
account.validate()?; // Err(ValidationErrors): every failed check
account.validate_first()?; // Err(ValidationError): the first one
Each ValidationError carries field, the path to the failing value
(friends[1]), and message, the validator's message with its placeholders
filled in (friends[1] must be at least 2 characters). An absent optional
field skips its checks.
Decoding runs the checks for you by default (validate_on_decode = "collect"
in comline.toml; "first" stops at the first failure), and a provider
refuses a call whose arguments fail them. Turning that off is a give-up with
its reason, validate_on_decode = { off = "why" }; then nothing runs until you
call validate(). See
validate_on_decode.
Properties are filled in when the code is generated: the argument you wrote, else the property's default, with constants resolved. Nothing about the validator exists at run time beyond the checks themselves.