Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonConstraint
Most validation stacks make you write every rule twice: once as the check, and once as the message that explains it to a person. The two live in different places — an attribute and a resource key, a builder call and a message override, a predicate and a string literal — and they drift. Someone widens a length limit and the error still quotes the old one.
Reified.Constraint has no second place to write it. A Constraint<'value> is a reusable description of
valid values, and the failure it produces is derived from that same description. check is the operation
that runs it.
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
ReifiedretryCount: 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.
intAn abbreviation for the CLI type . Basic Types
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
(|>): '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
satisfies: Constraint<'value> -> 'value -> boolAnswers whether a value satisfies a constraint, without building a violation. let retryCount = Constraint.between 0 10 3 |> Constraint.satisfies retryCount // true
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
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"
guard: Constraint<'value> -> 'value -> Result<'value,Violation>Runs a constraint and returns the unchanged value after success. let requiredName : Constraint<string> = Constraint.present "Alice" |> Constraint.guard requiredName |> Result.mapError Violation.render
Nobody wrote "expected a value between 0 and 10, but was 42". Change the bounds and the message changes
with them, because it was never a separate artefact to keep in step.
When you only want your own error
Plenty of code does not want a Violation at all. It wants a function that returns the application's own error case,
and nothing else. Constraint.guard keeps the checked value, and Result.orError throws the violation away in favour
of your error:
let validateEmail raw : Result<string, SignupError> =
raw
|> Constraint.guard Constraint.email
|> Result.orError InvalidEmailThat is the whole function. It is no longer, and no more ceremonious, than the equivalent hand-written predicate — and
unlike the predicate, Constraint.email is still the same value you can later put 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.
open Reified
module SignupRules =
open Reified.ConstraintDSL
let emailAddress : Constraint<string> = Constraint.all [ present; email; maxLength 254 ]
let age : Constraint<int> = atLeast 13
Reified04-constraints_21-constraint.md_page.SignupRulesReified.ConstraintDSLConstraint constructors usable without the Constraint. prefix inside a module that declares value rules. Optional vocabulary, not another abstraction. Opening it makes a declaration read minLength 3 instead of Constraint.minLength 3; everything here is the same value the qualified name returns. Some constructors are deliberately left out because they shadow names the same validation code is likely to need in scope: contains, distinct, all, any, length, and between shadow core F# operations. Reach for those as Constraint.contains, Constraint.all, and so on, even inside a module that has opened this DSL. Constraint execution (Constraint.satisfies, Constraint.check, Constraint.guard) is likewise always qualified: ConstraintDSL declares constraints, Constraint.* executes and inspects them. orError and mapError are structural adapters matching the corresponding Result operations. They let a constraint pipeline retain its input and finish with the application's error type without adding an Reified.Result dependency. module SignupRules = open Reified.ConstraintDSL let age : Constraint<int> = atLeast 13 let contact : Constraint<string> = Constraint.all [ present; email ] let requireContact value = value |> Constraint.guard contact |> orError EmailRequired
emailAddress: Constraint<string>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.
stringAn abbreviation for the CLI type . Basic Types
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
all: Constraint<'value> list -> Constraint<'value>Requires every constraint to hold, evaluating each in declaration order and accumulating failures. The empty list is the satisfied identity. F# visits list elements left to right, so annotate the binding when the first element is a type-directed value: let requiredName : Constraint<string> = Constraint.all [ Constraint.present; Constraint.lengthBetween 2 40 ]. let requiredName : Constraint<string> = Constraint.all [ Constraint.present; Constraint.lengthBetween 2 40 ]
present: Constraint<^value>Alias for .
email: Constraint<string>Alias for .
maxLength: int -> Constraint<^value>Alias for .
age: Constraint<int>intAn abbreviation for the CLI type . Basic Types
atLeast: 'a -> Constraint<'a>Alias for .
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.
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:
raw
|> Constraint.guard Constraint.email
|> Result.mapError InvalidEmail // InvalidEmail of ViolationThe rest of this page is about that second path. Nothing here forces you onto it.
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.
match "ab" |> Constraint.check (Constraint.minLength 3: Constraint<string>) with
| Ok () -> ()
| Error violation ->
Violation.tryExpectation violation
// Some (CardinalityAtom (Cardinality.Minimum 3))
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:
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.
Translation is cheap here because it is not a feature bolted on afterwards — it is the same split that removed the duplicated message in the first place. Shipping in one language still gets the benefit; you simply never build the resources.
The same declaration is read by everything downstream
There is one vocabulary, not several. A rule named in a module works unchanged in a refinement and in a schema, and the DSL spelling reads the same in all three places:
module RetryRules =
open Reified.ConstraintDSL
let count : Constraint<int> = Constraint.between 0 10
let retryCountRefinement =
Refinement.define RetryRules.count RetryCount _.Value
let schema =
Schema.int |> Schema.constrain RetryRules.countA constraint built from named parts lowers to JSON Schema, generates test data, and renders localizable messages; the equivalent hand-written lambda does none of those things, and says so honestly rather than pretending.
The catalogue resolves across text, collections, options, and maps by the type it is used at, so most uses need nothing extra:
Schema.text |> Schema.constrain Constraint.present
Reified.SchemaConstruction, composition, parsing, and checking for universal schemas.
text: Schema<string>Describes text input.
(|>): '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.ConstraintModuleCreates, executes, composes, and inspects constraints.
present: Constraint<^value>Requires a value to be inhabited according to its shape. Whitespace-only text is blank, as are null text, a null or empty collection or map, None, ValueNone, and an empty Nullable. Blankness means .NET's whitespace set plus U+FEFF, which is what lets the rule be exported; see nonBlankPattern. The shape is selected from the return type, so a reusable binding needs its annotation: let requiredName : Constraint<string> = Constraint.present. Applied where the type is already known — inside an annotated rule, or to a schema — no annotation is needed. let requiredName : Constraint<string> = Constraint.present
A standalone binding is the exception: the annotation is the only type information there, so it is what selects the shape.
module SignupRules =
open Reified.ConstraintDSL
let requiredName : Constraint<string> = present
let selectedPlan : Constraint<string option> = presentBoth are present; the annotation is what decides whether it means non-blank text or a supplied option.
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
Weighing this against DataAnnotations, FluentValidation, or 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.