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

Constraint and Result Tutorial

This tutorial validates a signup request and maps each Violation into an application error.

open Reified
open Reified.Result
open Reified.ResultDSL

type SignupRequest =
    { Name: string
      Email: string
      Age: int
      AcceptedTerms: bool }

type SignupError =
    | TermsNotAccepted
    | InvalidName of Violation
    | InvalidEmail of Violation
    | InvalidAge of Violation

type Signup =
    { Name: string
      Email: string
      Age: int }

Define the rules

A Constraint<'value> is a value, not a function. Annotate the binding: the catalogue's inline members pick their shape from the type they are used at, and the annotation is the only type information a standalone binding has.

let name : Constraint<string> =
    Constraint.all [ Constraint.present; Constraint.lengthBetween 2 40 ]

let email : Constraint<string> =
    Constraint.all [ Constraint.present; Constraint.email ]

let age : Constraint<int> =
    Constraint.atLeast 13

These are reusable declarations. The same values work unchanged in a Refinement and in a Schema.

Run them, keeping the value

Constraint.guard runs a constraint and returns the input on success, so the checked value flows onward:

let validateName (request: SignupRequest) =
    request.Name
    |> Constraint.guard name
    |> Result.mapError InvalidName

let validateEmail (request: SignupRequest) =
    request.Email
    |> Constraint.guard email
    |> Result.mapError InvalidEmail

let validateAge (request: SignupRequest) =
    request.Age
    |> Constraint.guard age
    |> Result.mapError InvalidAge

guard returns Result<'value, Violation>. Use Constraint.check when Result<unit, Violation> is enough, and Constraint.satisfies when a bool is.

This tutorial keeps the Violation by mapping it into an error case that carries it. Do that when something later needs the facts — classifying the failure, rendering it in another language, or showing the value that was rejected. The violation is comparable diagnostic data carrying the failing atom, so it survives to the boundary that decides how, and in which language, to say it. Rendering it here would settle that question too early.

When the application only wants its own error case, discard the violation instead with Result.orError:

let validateEmail (request: SignupRequest) =
    request.Email
    |> Constraint.guard email
    |> Result.orError EmailNotValid   // a plain case, no Violation payload

Both styles use the same constraint values, so this is a per-call-site choice, not an architecture.

Compose dependent results

let validateSignup (request: SignupRequest) =
    result {
        do!
            request.AcceptedTerms
            |> Result.require
            |> Result.orError TermsNotAccepted
        let! name = validateName request
        let! email = validateEmail request
        let! age = validateAge request
        return { Name = name; Email = email; Age = age }
    }

result { } stops at the first application error. AcceptedTerms is a bare bool with no subject value to preserve, which is what Result.require is for.

The request annotations are load-bearing: Signup is declared after SignupRequest and shares its field names, so F# would otherwise infer the wrong record from request.Name.

Use Schema when independent fields should accumulate path-aware sibling diagnostics instead of stopping at the first.

Render at the edge

let describe (renderer: Renderer) error =
    let field name violation =
        violation |> Violation.fullMessage (renderer |> Renderer.attribute name)

    match error with
    | TermsNotAccepted -> "The terms must be accepted."
    | InvalidName violation -> fieldAs "name" violation
    | InvalidEmail violation -> fieldAs "email" violation
    | InvalidAge violation -> fieldAs "age" violation

See Using constraints, Working with violations, Localization, and Result CE.