Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.Json

How it compares

The short version: most validation libraries treat a rule and its message as two artefacts. Reified treats the message as a projection of the rule, which is why one declaration can also produce JSON Schema, test data, and translations.

That difference matters in some projects and not others. This page tries to say which is which.

The one structural difference

Where the rule lives Where the message lives
DataAnnotations an attribute on a property ErrorMessage, or a resource key you name
FluentValidation a rule in a validator class a message override on that rule, or a localizer you wire up
Validus a validator function a message function supplied alongside it
Reified.Constraint a Constraint<'value> value derived from the constraint

Everything below follows from that row.

When the message is written separately, nothing makes the two move together. Widening MaxLength(40) to 80 leaves "must be 40 characters or fewer" behind until someone notices. Adding a language means enumerating every message a second time.

When the message is derived, a Violation carries the failing atom and the actual value rather than any prose, and text is produced at the edge:

42
|> Constraint.check (Constraint.between 0 10)
|> Result.mapError Violation.render
// Error "expected a value between 0 and 10, but was 42"

Change the bounds and the sentence changes. Point a Renderer at another culture and the same violation renders in that language. Neither required a message to be maintained anywhere.

The second difference: a constraint is data

Because a constraint is an inspectable value rather than a lambda, other machinery can read it:

let retryCount : Constraint<int> = Constraint.between 0 10

Schema.int |> Schema.constrain retryCount          // used in a schema
Refinement.define retryCount RetryCount _.Value    // used in a refined type

The same declaration lowers to JSON Schema and generates test data. A predicate — in any library, including Reified's own Constraint.custom escape hatch — cannot be inspected, and Reified says so rather than pretending otherwise.

If you only ever need to answer "is this valid?", the inspectability buys you nothing — but it does not cost you anything either. A constraint used that way is still one line, and you never have to see a Violation:

raw
|> Constraint.guard Constraint.email
|> Result.orError InvalidEmail

That returns Result<string, SignupError>, the same shape a hand-written predicate would give you, with the same amount of code. Reach for Result.mapError InvalidEmail instead when you want to keep the violation's facts. The concepts below are the price of the structured path, not of using a constraint at all.

DataAnnotations

Choose DataAnnotations when you are validating an ASP.NET MVC or Blazor model, want zero setup, and are content with what attributes can express. Model binding, client-side validation, and the surrounding tooling all work out of the box. That integration is real and Reified does not replace it.

Its limits are structural rather than incidental: attributes are declarations on properties, so a rule cannot be named, passed around, composed, or reused between two types that share a concept. Conditional and cross-field rules mean dropping to IValidatableObject. Messages are per-attribute strings or resource keys.

FluentValidation

Choose FluentValidation when you want a mature, widely-known library with a large community, ASP.NET integration, async and DI-driven validators, and a team already fluent in it. It is a good library and the ecosystem around it is far larger than Reified's.

Where it differs: a rule belongs to a validator for a type, rather than being a free-standing value. Sharing "what a valid retry count is" between two types means sharing a validator or repeating the rule. Messages are attached per rule and localization is wired up separately, so both remain artefacts to maintain. And a validator is ultimately a set of delegates, so nothing downstream can read it to produce a JSON Schema or generate matching test data.

Validus

Choose Validus when you want idiomatic F# validation with a small surface and few concepts. Validators compose, results accumulate, and there is very little to learn. For many F# applications that is the right amount of machinery.

Where it differs: validators are functions, so the message is supplied alongside the rule rather than derived from it, and nothing can inspect a validator afterwards. Reified's extra concepts — Violation, Renderer, interpreted versus opaque rules — exist to buy inspectability and derived messages.

They are not, however, an entry fee. A check that ends in your own error case reads the same length in either library, and the Reified version leaves you a rule you can reuse in a schema or a refined type later:

raw
|> Constraint.guard Constraint.email
|> Result.orError InvalidEmail

What Reified costs you

Stated plainly, because the sections above are about what it gives:

  • More concepts. Constraint, Violation, and Renderer are three things to learn where a predicate and a string are two.
  • F# only. There is no C# story.
  • A young ecosystem. Fewer answers, fewer integrations, and a smaller community than FluentValidation or DataAnnotations.
  • No model-binding integration. Reified validates values and models you hand it. Adapting an ASP.NET request into Data is application or host-library code, not an attribute added to an existing controller.

When Reified is worth it

The benefit compounds where the same value rule is needed in more than one place — a domain type's invariant, a request schema, a published JSON Schema, a generated fixture, a form message in two languages. If that describes your project, one declaration replaces four or five parallel ones.

If your validation is a handful of checks in one place and never leaves it, a predicate returning Result<_, string> is a perfectly good answer and you should use it.