Skip to content

Error codes

The machine-readable vocabulary, as constants on ValidationCodes.

csharp
if (error.Code == ValidationCodes.Required) { … }

Three things emit these codes — generated validators, hand-written ones, and the FluentValidation adapter — and they have to agree exactly, or a client switching on ValidationError.Code breaks depending on which engine found the error. Constants rather than literals is what stops that drifting silently.

CodeConstantEmitted by
requiredValidationCodes.Required[Required], rules.Required(…)
string_lengthValidationCodes.StringLength[StringLength], .Length(…)
rangeValidationCodes.Range[Range], .Range(…)
patternValidationCodes.Pattern[Pattern], .Pattern(…)
enumValidationCodes.Enum[AllowedValues], .AllowedValues(…)
array_boundsValidationCodes.ArrayBounds[ItemCount], .Count(…)
multiple_ofValidationCodes.MultipleOf[MultipleOf], .MultipleOf(…)
unique_itemsValidationCodes.UniqueItems[UniqueItems], .Unique(…)
predicateValidationCodes.Predicaterules.Ensure(…)
invalidValidationCodes.Invalidnothing in this library — see below

These are a wire contract. A client attaching messages to form inputs, or branching on failure kind, depends on them not moving.

range covers three shapes

[Range(1, 99)], [Range(Min = 1)] and [Range(Max = 99)] all report range. Only the message differs — "must be between 1 and 99", "must be at least 1", "must be at most 99" — because the failure is the same one and a client should not have to learn a second code for it.

An absent bound is never named. A specification setting only minimum used to compose the type's extreme into the message as the other bound.

The two that read oddly

enum for [AllowedValues]. Named for OpenAPI's enum keyword, which is where the code originates and what the first consumer already puts on the wire. Renaming it would break existing API consumers for cosmetics.

invalid, which nothing here emits. A validator receives a typed model, so by the time it runs the conversion has already succeeded — ?limit=abc where an integer was expected is the binder's failure, not validation's.

It lives in this vocabulary anyway, because the vocabulary is defined by the wire rather than by which library produced the value. A client switching on Code sees this alongside the rest, and splitting it out would leave nine codes in one place and the tenth somewhere a consumer has to already know to look.

It is deliberately distinct from range rather than folded into it: the value never became the right type, so no constraint on it was evaluated at all, and reporting range would claim one was.

Overriding a code

Every constraint carries Code:

csharp
[Required(Code = "pet_name_missing")]
public string? Name { get; init; }
csharp
rules.Ensure(x => x.Discount <= x.Price * 0.5m, code: "discount_too_large");

That promotes one rule into your contract deliberately, which is the intended way to let a client tell two rules on one field apart. Two Ensures on one field otherwise both report predicate, distinguished by their messages.

Why Ensure does not derive its code

Slugging or hashing the predicate would read better and was rejected: message and code have opposite churn requirements.

The message is human-facing and should track the rule — which is why Ensure's message is the predicate itself, rendered. The code is a wire contract. Derive it from the expression and widening a bound from 30 to 35 becomes a breaking change for every client switching on it, and reordering does the same if the code carries an ordinal.

Severity

Independent of the code.

csharp
public enum ValidationSeverity {
    Error   = 0,
    Warning = 1,
    Info    = 2,
}

Error is 0, so an uninitialised severity is never silently benign. Only Error makes ValidationResult.IsValid false — a result carrying nothing but warnings is valid and has errors, which is why IsValid and HasErrors are separate properties.

The values match FluentValidation's Severity exactly, which makes the adapter's mapping a cast rather than a table.

Messages

Composed at the call site from the field name and the bounds, not baked in as literals:

CodeMessage
requiredname is required.
string_lengthname must be between 1 and 100 characters.
string_lengthnotes must be at most 500 characters.
rangeage must be between 0 and 30.
enumstatus must be one of: available, pending, sold.
array_boundstags must be between 1 and 10 items.
predicatethe predicate, renderedstart < end.

Composing rather than emitting a literal per constraint keeps the same text out of the binary once per constraint site. Override with Message on any constraint.

Assert on Code in tests, not on Message. The code is the contract; the message is text that may legitimately be reworded.

Released under the MIT License.