Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultHow 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:
open Reified
42
|> Constraint.check (Constraint.between 0 10)
|> Result.mapError Violation.render
// Error "expected a value between 0 and 10, but was 42"
Reified(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
check: Constraint<'value> -> 'value -> Result<unit,Violation>Runs a constraint, returning why the value failed. let retryCount = Constraint.between 0 10 42 |> Constraint.check retryCount |> Result.mapError Violation.render
between: 'value -> 'value -> Constraint<'value>Requires a value inside the supplied inclusive bounds. let retryCount : Constraint<int> = Constraint.between 0 10
Reified.Result.ResultModuleFail-fast helpers over the standard F# Result type.
mapError: ('a -> 'b) -> Result<'c,'a> -> Result<'c,'b>Maps the error value of a result.
Reified.ViolationModuleInspects, traverses, and renders violations.
render: Violation -> stringRenders a violation as an English sentence fragment with no trailing punctuation, keeping conjunction and alternative groups distinct. Violation.render (Atomic (Expected (PresenceAtom Present, None))) // "value must be present"
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:
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
ReifiedRefinementsFsLiveDocsGeneratedPage23_F2D257DD2F93.RetryCountRetryCountintAn abbreviation for the CLI type . Basic Types
FsLiveDocsGeneratedPage23_F2D257DD2F93.RetryRulescount: Constraint<int>Reified.Constraint`1A reusable description of valid values, coupled to the closures that execute it. One constraint value serves direct checking, refined-value admission, Schema, documentation, and export. There is no separate check type: check is the operation, Constraint is the noun. Both closures are retained deliberately. They are not duplicates of one rule: test over a conjunction may stop at the first failing child, while check must run every child to accumulate. Interpreted atoms and custom predicates therefore have a Boolean path that does no violation work, and combinators preserve that property when every child has it. A customWith constraint supplies only a violation-returning callback, so its test runs that callback and discards the error. The description is never interpreted during execution. Closures are composed once, at construction.
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
between: 'value -> 'value -> Constraint<'value>Requires a value inside the supplied inclusive bounds. let retryCount : Constraint<int> = Constraint.between 0 10
Reified.SchemaConstruction, composition, parsing, and checking for universal schemas.
int: Schema<int>Describes a 32-bit integer.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
constrain: Constraint<'a> -> Schema<'a> -> Schema<'a>Requires a schema's values to satisfy a constraint. The same Constraint value serves direct checking, refinement, and Schema. For a value schema the constraint runs at that layer; for a model schema it runs after successful field admission and construction. Schema.text |> Schema.constrain (Constraint.lengthBetween 2 40)
Reified.Refinements.RefinementCreates and applies reusable refinement definitions.
define: Constraint<'underlying> -> ('underlying -> 'refined) -> ('refined -> 'underlying) -> Refinement<'underlying,'refined>Defines a refinement from one constraint, a constructor, and the reverse projection. Compose several rules with Constraint.all before defining, and reach for Constraint.custom when the rule is an arbitrary predicate. Both produce an ordinary constraint, so there is no separate plural or check-taking constructor. type RetryCount = RetryCount of int let retryCount = Refinement.define (Constraint.between 0 10) RetryCount (fun (RetryCount value) -> value)
_arg1: RetryCountvalue: intThe 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:
raw
|> Constraint.guard Constraint.email
|> Result.orError InvalidEmail // InvalidEmail, violation dropped
raw
|> Constraint.guard Constraint.email
|> Result.mapError InvalidEmail // InvalidEmail of Violation, facts keptThe 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, andRendererare 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
Datais 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.
Related comparisons
Reified.Resultagainst FsToolkit.ErrorHandling, for composing failures rather than describing valid values.- Decoding serialized input is a different job from validating a typed value: see
Reified.Parseand Schema rather than this page.