Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonConstraintDSL
Reified provides a DSL for more concise constraints. It exposes the common constructors without the Constraint.
prefix:
open Reified
module SignupRules =
open Reified.ConstraintDSL
let name : Constraint<string> =
Constraint.all [ present; minLength 2; maxLength 80 ]
let emailAddress : Constraint<string> =
Constraint.all [ present; email; maxLength 254 ]
let age : Constraint<int> =
atLeast 13
let handle : Constraint<string> =
Constraint.all [ present; alphanumeric; noneOf [ "admin"; "root" ] ]
Reified04-constraints_11-dsl.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
name: 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 .
minLength: int -> Constraint<^value>Alias for .
maxLength: int -> Constraint<^value>Alias for .
emailAddress: Constraint<string>email: Constraint<string>Alias for .
age: Constraint<int>intAn abbreviation for the CLI type . Basic Types
atLeast: 'a -> Constraint<'a>Alias for .
handle: Constraint<string>alphanumeric: Constraint<string>Alias for .
noneOf: 'a seq -> Constraint<'a>Alias for .
The DSL changes vocabulary, not semantics. Every name here returns the same Constraint<'value> the qualified name
returns; it is optional shorthand, not another abstraction.
Type-directed names
present, blank, optional, and the size family pick their shape from the type they are used at, so most of the
time they just work:
let name : Constraint<string> = Constraint.all [ present; maxLength 80 ]
let tags : Constraint<Item list> = Constraint.all [ atLeastOne; Constraint.distinct ]
Schema.text |> Schema.constrain presentIn each of those the surrounding type is already known, and the name resolves against it.
The one case that needs help is a binding whose only type information is the annotation — which is also the central story of naming a reusable rule. Dispatch runs on the return type, so without the annotation the compiler has nothing to select on:
let requiredName : Constraint<string> = present
let selectedPlan : Constraint<string option> = present
let requiredItems : Constraint<Item list> = minLength 1This selects a constraint; it does not parse, convert, or refine anything.
The catalogue
| Family | Names |
|---|---|
| Presence | present, blank, optional |
| Size | minLength, maxLength, lengthBetween, single, atLeastOne, atMostOne, moreThanOne |
| Comparison | equalTo, notEqualTo, greaterThan, lessThan, atLeast, atMost |
| Sign | positive, nonNegative, negative, nonPositive |
| Membership | oneOf, noneOf, notContains |
| Format | email, trimmed, numeric, alphanumeric, pattern |
| Number | multipleOf, finite, finite32 |
| Opaque | notWith, custom, customLocalized, customLocalizedWith, customWith, contramap |
| Other | describe, orError, mapError |
The sign and size names are spellings, not new primitives: positive is greaterThan 0 at the value's own numeric
type, and atLeastOne is minLength 1. Each builds the same atom its general form builds, so inspection, export,
and generation treat them identically.
Text sizes count code points
The size family measures text in Unicode code points, not UTF-16 code units. An emoji outside the Basic
Multilingual Plane is one character, so Constraint.length 1 accepts "\U0001F600" — where String.Length
would report 2.
let emoji : Constraint<string> = Constraint.length 1
Constraint.satisfies emoji "\U0001F600" // true
emoji: 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.
length: int -> Constraint<^value>Requires text or a collection to have exactly the supplied size. Text sizes count Unicode code points, so one emoji counts once even though it is two UTF-16 units. let code : Constraint<string> = Constraint.length 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
That is the definition users mean by "length", and it is the one JavaScript and .NET can be made to agree on, so
the same constraint reports the same size in a browser and on a server. Collections count elements, and maps count
entries; the atom is shape-neutral and an interpreter combines it with the surrounding shape to reach maxLength,
maxItems, or maxProperties.
Blankness is defined the same way on both runtimes: .NET's whitespace set plus U+FEFF. Adding U+FEFF makes a JSON
Schema validator's whitespace a strict subset of Reified's, which is what lets present and trimmed export a
sound \S pattern instead of staying runtime-only.
Names left off, and why
Some constructors are deliberately absent because they shadow names the same validation code is likely to need:
| Left off | Reason | Reach for |
|---|---|---|
all, any, contains, distinct, length, between |
shadow core F# operations | Constraint.all, Constraint.contains, … |
check |
shadows Schema.check |
Constraint.check |
test, guard |
execution stays qualified, consistent with check |
Constraint.satisfies, Constraint.guard |
The omissions are driven by collision alone, which is why notContains is exported although contains is not — no
core operation is named notContains. All constraint execution — Constraint.satisfies, Constraint.check,
Constraint.guard, and Constraint.inspect — stays qualified, so ConstraintDSL declares constraints and
Constraint.* executes and inspects them.
Result adapters
orError and mapError are small structural adapters matching the corresponding Result operations. They live here
because Reified.Constraint does not depend on Reified.Result, so a constraint pipeline can retain its input and
finish with the application's own error type without adding a package reference:
open Reified.ConstraintDSL
let requiredName (value: string) =
value |> Constraint.guard present |> orError NameRequired
let quantity value =
value |> Constraint.guard (atLeast 1) |> mapError InvalidQuantity