Validators
Status: compile-time checks done (core#32–40, target types core#105, aliases core#109) · run in Rust (phase 4b–4c, comline-rust#52) · TypeScript not yet (4d)
A validator is a named, parameterised check attached to a
struct or error field, or
to a type alias. The declaration, its validate block, and the @validators
annotation parse, freeze into structured IR, and are checked at comline build
time — unknown validator, bad keyword arg, undeclared params.* reference all
fail the build. Generated Rust runs them; the user guide's
validators page describes what
it emits.
Shape
The validator declaration
/// Checks a string's length is within bounds.
/// @min_chars: minimum length
/// @max_chars: maximum length
validator StringBounds for string {
min_chars: u32 = 0
max_chars: u32 = 1024
validate {
assert(value.length >= params.min_chars,
f"{value.name} must be at least {params.min_chars} characters")
assert(value.length <= params.max_chars,
f"{value.name} must be at most {params.max_chars} characters")
}
}
- Targets —
for TYPE[, TYPE]*, required (core#105). Primitives, arrays of them, or aliases (resolved). A field takes the validator only if its type, alias-resolved withoptionalset aside, is exactly one of the targets. A validator onstring[]checks the array, not its elements. No generics: a validator for several types lists them. - Properties —
name: Type [= default], the same shape as a struct field (no;, nooptional, no annotations). The validator's configuration, filled in at the use site. validate { … }— one or moreassert(condition, message)calls.condition— a small expression: member paths overvalue.*(the field being checked) andparams.*(this validator's properties), comparisons (== != >= <= > <), andand/orchains. No precedence — it is not evaluated, only checked.message— the f-string rule, reused:f"..."substitutes{value.…}/{params.…}placeholders, and a plain"..."prints as written.assert(c, m)holds whencis true; a falsecfails validation withm.- Frozen as
FrozenUnit::Validator { targets, properties, expression_block }, where the block isExpressionBlock { asserts: [Assert { condition, message, references }] }. Eachparams.*/value.*in aconditionis checked against the validator's properties; unknown roots and unknownparams.*fail the build.
The @validators field annotation
- A list of calls:
ValidatorName(prop = value, …), comma-separated keyword args. Empty args (A()) and empty lists ([]) parse. - Frozen onto the field as one
FrozenUnit::ValidatorRef { name, args }per call.validator.rschecks the name resolves to a declared (or imported)validator, that each keyword arg is one of its properties, and that a literal arg's type category (string / integer / bool) matches the property type (core#40). Arg values that areconstreferences are not type-checked — they stay text in the IR. - Works on
structanderrorfields (sameFieldgrammar).
Where validation runs
- Well-formedness (compile time). Done (core#37–40):
@validatorsnames a real validator, keyword args are real properties with matching literal types,validateblocks reference onlyvalue.*/ declaredparams.*. Gated oncomline build. - The actual check (run time).
StringBounds(min_chars = 3)on a field runs when a message is decoded — emitted into generated code / enforced by the runtime, the layer that does serialization. Phase 4, drafted below, not started.
Phasing
| Phase | State |
|---|---|
1 — validator declaration + typed properties |
✅ core#32 |
1b — validate { assert(…) } block |
✅ core#33 |
2 — @validators = [Name(a = 1)] list/call annotation values |
✅ core#34 |
3a — resolve @validators names |
✅ core#37 |
3b — check @validators keyword-arg names |
✅ core#38 |
3c — structured validate block, check params.* refs |
✅ core#39 |
| 3d — check keyword-arg literal types against the property types | ✅ core#40 |
3e — for target types; check fields and value.* against them, imported validators included |
✅ core#105 |
3f — @validators (and @deprecated) on a type alias |
✅ core#109 |
| 4 — runtime enforcement: generators emit the checks | 4a ✅ core#108; 4b–4c ✅ for Rust (comline-rust#52, runtime#12, generation#57, cli#71); 4d (TypeScript) not started |
Constraint: stays non-Turing-complete
validate is a checking language, not a scripting one. Whatever it grows —
let, helpers, regex — it must stay total: no recursion, no unbounded
iteration. A field check that might not terminate is a bug, not a feature. This
bounds the answers to the questions below.
Phase 4 — runtime enforcement (design)
What shipped (4b–4c, Rust)
The design below held, with these differences:
- Where
ValidationErrorlives. In comline-runtime'svalidationmodule (alloclayer), not a separatecomline_rtcrate. A package with validators but no protocol now depends on the runtime too. fieldis a path, not a name.ValidationError.fieldis aString:handle,friends[1],author.handle.{value.name}renders the same path, so a message says which element or nested value failed.- A trait, not inherent methods. Generated types implement
Validate, whosevalidate()returnsValidationErrorsandvalidate_first()aValidationError. The trait lets one type's check recurse into another's, across crates. - Recursion.
validate()checks nested structs and array elements, not just the type's own fields. - Resolution lives in the codegen kernel.
comline_codegen::validationworks out each value's checks (the alias chain's, innermost first, then the field's own) and binds every property, with constants andu32::MAX-style bounds resolved. A generator only lowers the result. - Params. A params struct borrows (
&'a str), which#[serde(try_from)]handles badly, so the dispatcher runs the check after decoding instead and refuses the call the way it refuses undecodable params.
What "run" means
StringBounds(min_chars = 3, max_chars = 12) on Message.recipient should turn
into an actual bounds check on the string, run when a Message crosses a trust
boundary (decoded from the wire, built by a client). The generated code carries
the check; nothing outside the generated code has to know the validator existed.
Proposed generated shape (rust)
impl Message {
/// Every `@validators` check on this value. Collects all failures.
pub fn validate(&self) -> Result<(), Vec<::comline_rt::ValidationError>> {
let mut failures = Vec::new();
// recipient: StringBounds(min_chars = 3, max_chars = 12)
{
let value = &self.recipient;
let value_name = "recipient";
// assert(value.length >= params.min_chars, ...)
if !((value.chars().count() as u64) >= 3u64) {
failures.push(::comline_rt::ValidationError {
field: value_name,
message: format!("{value_name} must be at least {} characters", 3u64),
});
}
// assert(value.length <= params.max_chars, ...)
if !((value.chars().count() as u64) <= 12u64) {
failures.push(::comline_rt::ValidationError {
field: value_name,
message: format!("{value_name} must be at most {} characters", 12u64),
});
}
}
if failures.is_empty() { Ok(()) } else { Err(failures) }
}
}
Decisions baked into that shape:
- An explicit
validate(&self)method, returningResult<(), Vec<ValidationError>>— collect all failures. Codegen also emitsvalidate_first(&self) -> Result<(), ValidationError>for callers that want to stop at the first. Deserialization calls neither automatically unlesscomline.tomlopts in. ValidationErroris a small shared type from a runtime support crate (comline_rt— the dormantruntimerepo's first real job):{ field: &'static str, message: String };ValidationErrors(Vec<_>)wraps it for the auto-run path. It is not one of the schema'serrordeclarations — validators aren't linked to anerrortoday.params.*are inlined at codegen.min_chars = 3bakes the literal3into the check; an unbound property emits its declared default. No runtime parameter passing, no per-instance validator object.value.*accessor vocabulary — small, fixed, and type-checked against the validator's targets (core#105):
| path | defined for | lowers to (rust) |
|---|---|---|
value |
any field | &self.<field> |
value.length |
string, arrays (T[]) |
.chars().count() / .len() |
value.name |
any field (compile-time string) | "<field>" |
value is whatever field the validator is attached to, and the field's type
is one of the validator's for targets. So each value.<accessor> is
checked once, at the declaration, against every target: value.length
in a for u32 validator is a build error there. Putting StringBounds on an
s32 field is a separate build error at that field. An unsupported or
unknown accessor never reaches codegen — the generator only ever sees
accessor/type pairs that already type-checked. The set grows deliberately
and stays non-computational.
- Numbers compare by value. A u8 value against a u32 (or s64)
parameter compares mathematically; codegen widens or checks the conversion,
never narrows. This is what lets one validator list several numeric
targets without generics.
- Operators map straight through: == != >= <= > < → the rust operators,
and / or → && ||, each comparison parenthesised (the grammar has no
precedence, so codegen supplies the grouping).
- Message interpolation — the {value.name} / {params.x} placeholders
become format! arguments with the inlined values.
Prerequisite: a real condition AST in the IR (4a) — done
Shipped in core#108, in the same IR revision as structured types (core#95).
Before it, the frozen Assert kept condition only as canonical text plus
a flat references: Vec<String>. That was enough for the compile-time checks
(does every params.x name a property?), but a code generator needs the
operator tree. Assert now carries both:
Assert {
condition: String, // canonical text, kept for messages
message: String,
references: Vec<String>, // kept: the checks still use it
expr: Condition, // the tree a generator emits from
}
struct Condition { first: Comparison, rest: Vec<(LogicalOp, Comparison)> }
struct Comparison { left: Operand, op: CompareOp, right: Operand }
enum LogicalOp { And, Or }
enum CompareOp { Eq, Ne, Ge, Le, Gt, Lt }
enum Operand { Path(Vec<String>) /* value.length */, Integer(i64), String(String) }
The chain is flat because the grammar has no precedence: a generator groups it left to right, parenthesising each comparison.
Phasing
| Sub-phase | Work |
|---|---|
| 4a | Done (core#108). Freeze the validate condition as a structured expression tree (keep the text + references the checks use). |
| 4b | Done for Rust. rust generator emits validate() (collect-all) and validate_first() (fail-fast); stand up the comline_rt support crate with ValidationError / ValidationErrors; implement the value.* accessor set. |
| 4c | Done for Rust. comline.toml validate_on_decode knob (off / collect / first) — #[serde(try_from)] shim in the rust target; default off. Sketch below. |
| 4d | The other generators (python / typescript / luau) once they exist; imported / cross-schema validators (needs the imported schema's IR at generate time, like multi-version generate). |
Auto-validation on decode (4c)
Whether decode runs the checks is the consumer's call, not the schema's — it
changes the shape and cost of generated decode (a clone per decode, no "parse
now, validate later"), which is a codegen concern. So it's a [generate] key in
the consumer's comline.toml, overridable per
[[generate.target]], slotting into the existing
defaults → [generate] → [[generate.target]] → env → CLI precedence:
[generate]
# Run the validators as part of decoding. Off by default.
# "off" — emit validate() / validate_first(), never call them for you
# "collect" — decode, then collect-all; Err is ValidationErrors(Vec<_>)
# "first" — decode, then fail-fast; Err is ValidationError
validate_on_decode = "off"
[[generate.target]]
language = "rust"
validate_on_decode = "collect"
- rust —
"collect"/"first"add#[serde(try_from = "…")](a shadow type that decodes the raw shape, then callsvalidate()/validate_first()in itsTryFrom);"off"leaves decode untouched. - Matching
COMLINE_GENERATE_VALIDATE_ON_DECODEenv var and--validate-on-decodeflag, like the other[generate]keys. - Folds into
comline.tomlwhen 4c ships.
Decided
- Trigger point.
validate()is always emitted (4b) and the caller invokes it. Auto-running it on decode is opt-in viacomline.toml, not the IDL — it's a codegen concern, not a schema contract. Default: off. - Fail-fast vs collect-all. Not a schema property and not a package
settings— it's how the consumer wants failures surfaced, and a form UI and a hot-path decoder want opposite things from the same type. So codegen emits both verbs (validate()collect-all,validate_first()fail-fast) and the caller picks; the auto-run path picks withvalidate_on_decode. A package-level toggle would fragment.validate()'s meaning across dependencies, so there isn't one. Either way codegen must emit panic-safe checks — an assert that indexes past what an earlier assert guards has to evaluate to failure, not crash. - Unsupported
valueaccessor. Acomline builderror at the validator, checked against itsfortargets (see the accessor table).lengthis an attribute of the types that have one; on a type that doesn't, the schema never compiles — it never reaches codegen, let alone runtime. There is no "emit vs. don't emit" fork.
Open questions
- Runtime
params. The design inlines every arg at codegen (literal orconst). Whether a dynamic arg is ever wanted — and what it would do to the inline model — is undecided; no position yet. assertvocabulary. Onlyassert(cond, msg)today. Whether it grows (aregex/matcheshelper,letbindings, …) is undecided. Whatever the answer, it stays bounded by the non-TC constraint.
Resolved along the way
- Property terminators — no
;; matches struct fields (core#32). - Kwarg separator — commas:
min_chars = 3, max_chars = 12(core#34). string_bounds.ids— the invertedassertin the stdlib example was corrected when the block landed (core#33).- Scoped-const property defaults —
= u32::MIN/= SOME_CONSTnow parse (Expression::Path/Expression::FString, core#36). validateas text vs. AST (for the checks) — resolved by the structuredAssertin core#39. A full operator AST is phase 4a.