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

How it compares

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.

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

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:

Verified
open Reified

42
|> Constraint.check (Constraint.between 0 10)
|> Result.mapError Violation.render
// Error "expected a value between 0 and 10, but was 42"
Requires & types
Requires: open Reified; open Reified.Result (packages: Reified.Constraint, Reified.Result)

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:

Isolated
open Reified
open Reified.Refinements

type RetryCount = RetryCount of int

module RetryRules =
    let count : Constraint<int> = Constraint.between 0 10

Schema.int |> Schema.constrain RetryRules.count                                // used in a schema
Refinement.define RetryRules.count RetryCount (fun (RetryCount value) -> 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 need to answer "is this valid?", inspectability buys you nothing but costs nothing either. A constraint used that way is still one line, and you never have to see a Violation. Both mappings live in one place: discard the violation with Result.orError, or keep its facts with Result.mapError:

Verified
open Reified
open Reified.Result

type SignupError =
    | EmailRejected
    | InvalidEmail of Violation

// Discard the Violation and keep only the application error case.
let emailOr (raw: string) : Result<string, SignupError> =
    raw
    |> Constraint.guard Constraint.email
    |> Result.orError EmailRejected          // EmailRejected, violation dropped

// Keep the Violation's facts for rendering or redisplay.
let emailWithFacts (raw: string) : Result<string, SignupError> =
    raw
    |> Constraint.guard Constraint.email
    |> Result.mapError InvalidEmail          // InvalidEmail of Violation, facts kept
Requires & types
Requires: open Reified; open Reified.Result (packages: Reified.Constraint, Reified.Result)
val emailOr: string -> Result<string,SignupError>
val emailWithFacts: string -> Result<string,SignupError>

The orError form returns Result<string, SignupError>, the same shape a hand-written predicate would give you. The mapError form keeps the violation's facts when the extra concepts should earn their keep. 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: an attribute is a declaration attached to a property, not a free-standing value. A reusable attribute class (including a custom one) can be applied to many properties, but each application is a separate declaration — there is no value your code can name, pass around, compose, or inspect at runtime. 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. Its ecosystem is far larger than Reified's.

Where it differs for a value rule: a rule belongs to a validator for a type, rather than being a free-standing value. FluentValidation can share rules through reusable validators and Include, but "what a valid retry count is" still lives in a validator, ultimately a set of delegates, not in a value your code can hold, pass around, or inspect. That is the Constraint-level difference. vs FluentValidation compares the Schema-level consequence: one Reified declaration producing JSON Schema, a codec, and UI metadata where a validator leaves each as a separate artifact.

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.

You do not need those concepts for every check. 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. The two error mappings are shown together in The second difference above.

What Reified costs you

  • 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. One declaration can replace those parallel declarations.

If your validation is a handful of checks in one place and never leaves it, a predicate returning Result<_, string> is enough.