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

ConstraintDSL

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" ] ]

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 present

In 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 1

This 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

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 executionConstraint.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