validation

package
v0.26.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 13 Imported by: 0

README

validation

Package validation implements the SDK's constraint engine over the internal/core/app/validation port: the built-in constraints (Required, AtLeast, AtMost, Between, Length, Count, OneOf, Matches), the combinators that compose them (All, First, Field, Each, Must, Check), and the struct-tag front end (Struct) that compiles a cached plan per type.

Two front ends, one contract. The programmatic path uses no reflection — each descent is an accessor function. The tag path buys ergonomics with reflect and caches the compiled plan, and the trade is measured rather than asserted: see BENCH.md.

Collecting every violation is the default; First and StructConfig.StopAtFirst really stop rather than filtering a full report. Every constraint that can be misconfigured is refused at construction, and every dialect construct the SDK declines to support is refused by name, with the fix in the message.

Facade: pkg/v1/app/validation. ADR 0046. See CLAUDE.md.

Documentation

Overview

Package validation — the ordered-value bounds constraints.

Package validation — the struct-tag compiler. Everything here runs once per type, at construction; nothing here runs during a validation.

Package validation — the descent combinators. These are what make a violation LOCATED on a nested structure, and they do it with an accessor function rather than reflection: the programmatic path never imports reflect, so a hot validator is a chain of direct field reads the compiler can inline.

Package validation — compiling the `dive` descent.

Descent is EXPLICIT. A nested struct is walked only when its field says dive, never automatically: automatic recursion would walk into time.Time, net.IP and every other struct that happens to be a field, and a rule engine that silently reaches places the author did not name is a rule engine whose report cannot be trusted.

Package validation — hosts elementPlan, the rule set one element of a dived slice or array is checked against.

Package validation — one embedded struct encoding/json descends into.

Package validation — which field a JSON key actually decodes into.

Package validation — the regular-expression constraint.

Package validation — the compiled struct-tag plan and its per-type cache.

A plan is compiled ONCE per (type, mode) and reused by every validation of that type. Parsing a struct tag on every request would put string splitting and reflect.StructField lookups on the hot path of every field of every request — which is exactly the shape of the "we added validation and the p99 moved" post-mortem. See BENCH.md for what the cache is worth.

Package validation — hosts planKey, the identity of a compiled plan.

Package validation — the presence constraint.

Package validation — a field the plan puts rules on, located for the reach check.

Package validation — the refusal helpers. Every refusal names what was wrong in structured fields; the Public message stays a fixed literal.

Package validation — the closed set of built-in rule names. A rule name is part of the published contract: it is what a ViolationValue.Rule carries, what an application keys its own translated messages on, and what a validate struct tag spells. Adding one is a decision the SDK maintains forever.

Package validation — the tag dialect: what a rule item may say, and what it is refused for saying.

Package validation — the kind-bound halves of the tag dialect. Each builder resolves the field's kind ONCE, at compile time, and returns a closure that reads the value through the one accessor that kind supports. A validation therefore performs no type dispatch at all.

Package validation — the tag half of set membership.

Package validation — the set-membership constraint.

Package validation — the size constraints: how long a string is, and how many elements a slice holds. Two rules rather than one, because "at most 30 characters" and "at most 30 entries" are different requirements with different fixes, and Go's type system cannot spell len over both anyway.

Package validation — the struct-tag front end.

Package validation implements the SDK's constraint engine over the internal/core/app/validation port: a small, closed set of built-in constraints, the combinators that compose them and descend into nested structures, and a struct-tag front end that compiles a cached plan per type (ADR 0046).

Two entry shapes, one contract. The PROGRAMMATIC path is generic and uses no reflection at all — Field and Each take an accessor function, so every descent is a direct field read the compiler can inline. The TAG path trades that for ergonomics and is measured rather than assumed; see BENCH.md.

Both produce a core/app/validation.Constraint, so they compose with each other: a struct-tag validator and a hand-written cross-field rule can be handed to All and appear in one report.

Collecting EVERY violation is the default and First is the opt-in. A form that reports one error at a time makes the user submit it five times.

Package validation — the single place a ViolationValue is minted, so every built-in constraint reports the same shape.

Index

Constants

View Source
const Unbounded int = -1

Unbounded is the upper bound that means "no ceiling". It is spelled rather than implied by a negative number so a reader of Length(8, Unbounded) does not have to know the convention to understand the line.

Variables

This section is empty.

Functions

func All

func All[T any](constraints ...corevalidation.Constraint[T]) corevalidation.Constraint[T]

All runs every constraint and returns everything they found, in composition order. It is the default shape of the engine: a caller who wanted one answer can take the first violation, but a caller who needed all of them cannot recover the ones a short-circuit never computed.

All with no constraint is legitimate and accepts everything — a validator without rules is not a broken validator (ADR 0031). What is refused is a constraint that could never be satisfied, and that is refused by its own constructor, before it can be composed.

func AtLeast

func AtLeast[T cmp.Ordered](lo T) corevalidation.Constraint[T]

AtLeast refuses a value below lo. The bound is inclusive: lo itself passes. It is the programmatic spelling of the `min=` struct tag; the name avoids shadowing Go's `min` builtin at every call site that dot-imports nothing.

It cannot be misconfigured — every value of an ordered type is a legitimate floor — so it returns a Constraint directly.

func AtMost

func AtMost[T cmp.Ordered](hi T) corevalidation.Constraint[T]

AtMost refuses a value above hi. The bound is inclusive: hi itself passes. It is the programmatic spelling of the `max=` struct tag.

It cannot be misconfigured, for the same reason as AtLeast.

func Between

func Between[T cmp.Ordered](lo, hi T) (constraint corevalidation.Constraint[T], err error)

Between refuses a value outside the closed interval [lo, hi].

It REFUSES lo > hi at construction rather than validating in silence. An inverted interval is satisfied by nothing, so the alternative is a validator that rejects every value it is ever shown while reporting a plausible per-field message — the ADR 0031 failure mode, in its rejecting form. The caller who wrote Between(10, 1) meant Between(1, 10), and finds out now rather than from a support ticket.

lo == hi is NOT a misconfiguration: an interval of exactly one value is a coherent requirement, and refusing it would be the SDK second-guessing a legitimate intent.

func Check

func Check[T any](value T, constraints ...corevalidation.Constraint[T]) error

Check runs constraints against value at the root path and converts the result to the SDK error model. It is the one-line bridge to an error-typed contract — principally core/app/config.Validator's Validate() error, which is how a decoded config struct self-checks after config.Load.

It returns a genuine nil interface when nothing was found wrong.

func Count

func Count[E any](minLen, maxLen int) (constraint corevalidation.Constraint[[]E], err error)

Count refuses a slice whose ELEMENT count falls outside [min, max]. It is also how presence is expressed for a slice, which Required cannot cover because a slice is not comparable: Count(1, Unbounded).

max may be Unbounded. The bound rules are those of Length.

func Each

func Each[T, E any](name string, get func(T) []E, constraints ...corevalidation.Constraint[E]) (constraint corevalidation.Constraint[T], err error)

Each applies constraints to EVERY element of a slice member of T reached through get, reporting each element's violations at the parent path extended by name and the element's index — "addresses[2]".

Every element is visited, including those after the first failing one: an import that reports row 3 and stops makes the operator run the import six times. The elements are visited in slice order, so the report is stable across runs, which is what makes two reports diffable.

A nil slice contributes nothing — it holds no element to be wrong about. Requiring at least one element is Count, a different question asked at the member itself.

A nil get, or an empty name, is refused at construction for the reasons Field gives.

func Field

func Field[T, F any](name string, get func(T) F, constraints ...corevalidation.Constraint[F]) (constraint corevalidation.Constraint[T], err error)

Field applies constraints to a member of T reached through get, reporting every violation at the parent path extended by name.

name is the member's name as the CALLER wants it to appear in a path; the SDK does not read it off the type, because the programmatic path never touches reflection. Struct derives it from the json tag instead.

A nil get is REFUSED at construction. It is not a missing rule — the rules are right there — it is a missing way to reach the value they are about, so carrying it would produce a validator that runs, reports nothing, and looks exactly like a passing one. That is the ADR 0031 trap in its accepting form, which is the more dangerous of the two. An empty name is refused for the neighbouring reason: a violation whose path silently points at the parent is a violation that is not located.

func First

func First[T any](constraints ...corevalidation.Constraint[T]) corevalidation.Constraint[T]

First runs the constraints in order and stops at the one that refuses, returning only its violations. It is a REAL short-circuit — the constraints after the first failure are never evaluated — so it is honest to reach for when a later check is expensive or would be meaningless.

It is deliberately a combinator rather than a mode flag on a runner. A flag that truncated the report after the fact would claim a saving it did not make; a combinator that stops really stops, and the caller can see where.

func Length

func Length(minRunes, maxRunes int) (constraint corevalidation.Constraint[string], err error)

Length refuses a string whose RUNE count falls outside [min, max].

It counts runes, not bytes: "at most 30 characters" is what a form says, and counting bytes would refuse a perfectly ordinary accented name at 16 characters. It does not count grapheme clusters — an emoji built from a zero-width-joiner sequence counts as several runes — because clustering needs a Unicode table the SDK does not carry, and claiming otherwise would be worse than saying so.

max may be Unbounded. A negative min, or min > max when max is bounded, is refused at construction (ADR 0031).

func Matches

func Matches(pattern string) (constraint corevalidation.Constraint[string], err error)

Matches refuses a string that the pattern does not match anywhere. Anchor it with ^ and $ when the whole string must match — that is regexp's own convention and inventing a different one here would surprise everyone who already knows it.

The pattern is COMPILED AT CONSTRUCTION and an uncompilable one is refused (ADR 0031). A pattern compiled lazily on first use would turn a typo in a rarely-exercised rule into a failure at the worst possible moment; compiling now means a bad pattern cannot reach production at all.

Why this is safe against the input

Go's regexp is RE2: no backtracking, linear time in the length of the input. A pattern cannot be turned into a denial of service by the value it is shown, which is what makes it acceptable to run one on untrusted input at all. It also means the SDK does NOT need — and does not impose — an input length cap here; cap the length with Length if the field has one, for its own reasons.

Matches is deliberately absent from the struct-tag dialect. See Struct.

func Must

func Must[T any](constraint corevalidation.Constraint[T], err error) corevalidation.Constraint[T]

Must returns constraint, panicking when err is non-nil. It is the regexp.MustCompile / template.Must idiom, for the same situation: a constraint built from source literals at package initialisation, where the only possible error is a defect the binary must not start with.

Use it for a package-level var. Do NOT use it on a bound that comes from configuration — there the error is a real runtime condition and must be returned, which is why every fallible constructor hands one back.

func OneOf

func OneOf[T comparable](allowed ...T) (constraint corevalidation.Constraint[T], err error)

OneOf refuses a value absent from allowed.

It REFUSES an empty allowed set at construction. An empty set is satisfied by nothing, so the alternative is a constraint that rejects every value it is ever shown while looking like a working rule — the ADR 0031 failure mode in its rejecting form. "No allowed values" is not a permissive rule, it is a forgotten argument.

Duplicates in allowed are accepted and collapse: repeating a value does not change the set it denotes, and refusing a duplicate would turn a harmless copy-paste in a generated list into a start-up failure.

func Required

func Required[T comparable]() corevalidation.Constraint[T]

Required refuses the zero value of T.

What "absent" means here, and what it cannot mean

A Go value type has no representation for "not supplied": the zero value is what a decoder leaves behind for a field the input never mentioned AND for a field the input explicitly set to 0, "" or false. Required therefore refuses BOTH, and it cannot tell them apart — no constraint operating on T can.

This is stated rather than hidden because it changes designs: a caller who must distinguish "the operator did not configure a timeout" from "the operator configured a timeout of zero" declares the field as a POINTER, and Required on *time.Duration then means exactly "was supplied". The SDK will not guess which of the two a bare zero meant.

Required needs no configuration and therefore cannot be misconfigured, which is why it returns a Constraint directly rather than a (Constraint, error).

It is constrained to comparable T because presence is "differs from the zero value", and a slice is not comparable. Presence of a slice is its element count: use Count(1, Unbounded).

func Struct

func Struct[T any](cfg StructConfig) (constraint corevalidation.Constraint[T], err error)

Struct compiles the `validate` struct tags of T into a Constraint. The plan is compiled once per (type, mode) and cached, so calling Struct again for the same T is one map read — which is what makes it usable inside a core/app/config.Validator's Validate method:

func (c Conf) Validate() error {
    rules, err := validation.Struct[Conf](validation.StructConfig{})
    if err != nil {
        return err
    }
    return validation.Check(c, rules)
}

The result IS a Constraint, so tags and hand-written rules compose into one report: All(tagRules, crossFieldRule).

A type with no validate tag is legitimate

It compiles to an empty plan and accepts everything. "No rules" is a rule set, and refusing it would make it impossible to add validation to a type incrementally. What is refused is a tag that cannot be honoured — and it is refused HERE, at compile time, never at the first request (ADR 0031).

The dialect is a strict subset of the programmatic API, on purpose

Accepted: required, min, max (numbers), minlen, maxlen (string runes), mincount, maxcount (slice/array elements), oneof=a|b|c, dive.

Refused BY NAME, each with a message that says what to do instead: pattern and regex (a regular expression cannot live in a comma-separated tag without inventing an escape dialect, and a SILENTLY TRUNCATED pattern is the exact class of failure this SDK refuses — compose Matches in code); email, url and uuid (see ADR 0046 §What is refused); dive on a map; any unknown rule.

Types

type StructConfig

type StructConfig struct {
	// StopAtFirst compiles a plan that stops at the first violation instead of
	// collecting every one. The zero value collects everything, because a form
	// that reports one error at a time makes the user submit it five times.
	//
	// It is compiled INTO the plan, nested plans included, so a stop-at-first
	// validator really does stop: the later fields are never read and the
	// later rules never run. It is not a filter applied to a full report.
	StopAtFirst bool
}

StructConfig tunes a compiled struct validator.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL