Skip to content

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 with optional set aside, is exactly one of the targets. A validator on string[] 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 ;, no optional, no annotations). The validator's configuration, filled in at the use site.
  • validate { … } — one or more assert(condition, message) calls.
  • condition — a small expression: member paths over value.* (the field being checked) and params.* (this validator's properties), comparisons (== != >= <= > <), and and / or chains. 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 when c is true; a false c fails validation with m.
  • Frozen as FrozenUnit::Validator { targets, properties, expression_block }, where the block is ExpressionBlock { asserts: [Assert { condition, message, references }] }. Each params.* / value.* in a condition is checked against the validator's properties; unknown roots and unknown params.* fail the build.

The @validators field annotation

struct Message {
    @validators = [StringBounds(min_chars = 3, max_chars = 12)]
    recipient: string
}
  • 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.rs checks 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 are const references are not type-checked — they stay text in the IR.
  • Works on struct and error fields (same Field grammar).

Where validation runs

  1. Well-formedness (compile time). Done (core#37–40): @validators names a real validator, keyword args are real properties with matching literal types, validate blocks reference only value.* / declared params.*. Gated on comline build.
  2. 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 ValidationError lives. In comline-runtime's validation module (alloc layer), not a separate comline_rt crate. A package with validators but no protocol now depends on the runtime too.
  • field is a path, not a name. ValidationError.field is a String: 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, whose validate() returns ValidationErrors and validate_first() a ValidationError. 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::validation works out each value's checks (the alias chain's, innermost first, then the field's own) and binds every property, with constants and u32::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, returning Result<(), Vec<ValidationError>> — collect all failures. Codegen also emits validate_first(&self) -> Result<(), ValidationError> for callers that want to stop at the first. Deserialization calls neither automatically unless comline.toml opts in.
  • ValidationError is a small shared type from a runtime support crate (comline_rt — the dormant runtime repo'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's error declarations — validators aren't linked to an error today.
  • params.* are inlined at codegen. min_chars = 3 bakes the literal 3 into 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 calls validate() / validate_first() in its TryFrom); "off" leaves decode untouched.
  • Matching COMLINE_GENERATE_VALIDATE_ON_DECODE env var and --validate-on-decode flag, like the other [generate] keys.
  • Folds into comline.toml when 4c ships.

Decided

  • Trigger point. validate() is always emitted (4b) and the caller invokes it. Auto-running it on decode is opt-in via comline.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 with validate_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 value accessor. A comline build error at the validator, checked against its for targets (see the accessor table). length is 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 or const). Whether a dynamic arg is ever wanted — and what it would do to the inline model — is undecided; no position yet.
  • assert vocabulary. Only assert(cond, msg) today. Whether it grows (a regex / matches helper, let bindings, …) 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 inverted assert in the stdlib example was corrected when the block landed (core#33).
  • Scoped-const property defaults — = u32::MIN / = SOME_CONST now parse (Expression::Path / Expression::FString, core#36).
  • validate as text vs. AST (for the checks) — resolved by the structured Assert in core#39. A full operator AST is phase 4a.