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

Constraint

When a check and its message are defined separately, changing a length limit can leave the error quoting the old one. A Constraint<'value> describes valid values and derives failures from the same description. Constraint.check runs it.

Verified
open Reified

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

3 |> Constraint.satisfies retryCount   // true

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

3 |> Constraint.guard retryCount  // Ok 3

Change the bounds and the derived message changes with them.

When you only want your own error

When a function needs only the application's error case, Constraint.guard keeps the checked value and Result.orError discards the violation:

Fragment
let validateEmail raw : Result<string, SignupError> =
    raw
    |> Constraint.guard Constraint.email
    |> Result.orError InvalidEmail

You can reuse Constraint.email in a refinement, a schema, or a JSON Schema document without rewriting the rule.

Result.orError comes from Reified.Result. Without that package, Result.mapError (fun _ -> InvalidEmail) does the same thing.

Where the rules for a model live

Once there is more than one rule, they belong together in a module rather than scattered across the code that uses them. That is also where Reified.ConstraintDSL earns its place: it exposes the same constructors without the Constraint. prefix, which reads well when the module name already says what these rules are for.

Verified
open Reified

module SignupRules =
    open Reified.ConstraintDSL

    let emailAddress : Constraint<string> = Constraint.all [ present; email; maxLength 254 ]
    let age : Constraint<int> = atLeast 13

Open it inside the module, not at the top of a file, present and email are ordinary words, and their meaning should be obvious from the two lines above them. The DSL changes vocabulary, not semantics: every name returns the same Constraint<'value> the qualified name returns. Code elsewhere refers to SignupRules.age, and the rest of this page uses Constraint. spellings so each example stands on its own.

→ ConstraintDSL

Reach for the structured path when the extra facts earn their keep, when you want to classify failures, render messages in more than one language, or report several field failures at once. Then keep the violation and Result.mapError it into a case that carries it:

Fragment
raw
|> Constraint.guard Constraint.email
|> Result.mapError InvalidEmail   // InvalidEmail of Violation

The failure carries facts, not prose

A Violation holds the failing constraint atom and, where Reified can represent it, the actual value. It carries no language and no formatting. That is what keeps it comparable data you can retain, assert on in a test, and pass across a boundary without dragging a culture or a ResourceManager along with it.

Verified
open Reified

match "ab" |> Constraint.check (Constraint.minLength 3: Constraint<string>) with
| Ok () -> ()
| Error violation ->
    printfn "%A" (Violation.tryExpectation violation)
    // Some (CardinalityAtom (Cardinality.Minimum 3))

    printfn "%A" (Violation.tryActual violation)
    // Some (ConstraintValue.Integer 2L)

Application code can classify a failure without parsing a sentence, and a test can assert on the fact rather than on the wording.

One language, or many, from the same violation

Prose happens at the rendering edge. Violation.render is the zero-dependency English default and needs no setup at all. When you need more, a Renderer carries the language and the document context while the violation carries the facts:

Verified
open Reified

let renderer = Renderer.english

let violation =
    match "" |> Constraint.check Constraint.present with
    | Error violation -> violation
    | Ok () -> failwith "unreachable"

let field = renderer |> Renderer.context "signup" |> Renderer.attribute "name"

violation |> Violation.message field      // "must be present"
violation |> Violation.fullMessage field  // "Name must be present"

Give the renderer a different culture and the identical violation reads "Le nom doit être renseigné", with contextual fallback, and without any application code walking a violation tree or reproducing Reified's key catalogue.

If you ship in one language, you do not need translation resources.

The same declaration is read by everything downstream

A rule named in a module works unchanged in a refinement and in a schema. ConstraintDSL covers the full vocabulary, the type-directed names, and the result adapters; keep one small rule module here and reach for that page for details.

The same value facts appear at three further levels:

  • Refined values use a constraint to construct invariant-carrying domain types.
  • Schema adds structured input, paths, accumulation, and wire interpreters.
  • JSON Schema publishes what the target can enforce, and documents the rest.

Where to go next

For a comparison with DataAnnotations, FluentValidation, and Validus, read How it compares.

Otherwise take ConstraintDSL for the full vocabulary and how to write a rule module, then Using constraints for composition and keeping the input, Working with violations for rendering and inspecting failures, Interpreted and opaque for what makes a rule inspectable and what an escape hatch costs, and Localization for translating failures, with Adding a language for the working order of a new translation and Fable support for the JavaScript target.