Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonConstraint 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 }
ReifiedResultReified.ResultDSLThe concise result vocabulary: the result { } computation expression, its accumulating result.list { } / result.array { } variants, and the lightweight admission functions (okIf, failIf, require, orError, mapError). Optional and opt-in, in the same shape as Reified.DataDSL, Reified.ConstraintDSL, and Reified.SchemaDSL: open Reified.Result for Result, then open Reified.ResultDSL for this vocabulary. Deliberately small: generic combinators such as map, bind, orElse, tap, and the traversal helpers stay qualified as Result.map, Result.bind, and so on.
04-constraints_60-tutorials_10-constraint-result.md_page.SignupRequestName: stringstringAn abbreviation for the CLI type . Basic Types
Email: stringAge: intintAn abbreviation for the CLI type . Basic Types
AcceptedTerms: boolboolAn abbreviation for the CLI type . Basic Types
04-constraints_60-tutorials_10-constraint-result.md_page.SignupErrorTermsNotAcceptedInvalidNameReified.ViolationWhy a value failed its constraint. A diagnostic contract, not an application error union. Domain code maps a whole violation once with Result.mapError; Schema adds the path at which it occurred. Violations are plain comparable data. No closure and no constraint description is reachable from one, so structural equality holds and a violation can be retained and compared long after the constraint that produced it went out of scope. There is no promised wire format. Reified-produced groups are never empty and never unary: a single failing child is returned directly rather than wrapped. The first * rest shape encodes non-emptiness only; non-unarity is a normalization invariant.
InvalidEmailInvalidAge04-constraints_60-tutorials_10-constraint-result.md_page.SignupDefine 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
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>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
lengthBetween: int -> int -> Constraint<^value>Requires a text or collection size inside the supplied inclusive bounds. let name : Constraint<string> = Constraint.lengthBetween 2 40
email: Constraint<string>email: Constraint<string>Requires text to match Reified's pragmatic email shape, ^[^@]+@[^@]+$. let contact : Constraint<string> = Constraint.email
age: Constraint<int>intAn abbreviation for the CLI type . Basic Types
atLeast: 'value -> Constraint<'value>Requires a value greater than or equal to the supplied bound. 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
validateName: SignupRequest -> Result<string,SignupError>request: SignupRequest04-constraints_60-tutorials_10-constraint-result.md_page.SignupRequestName: string(|>): '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
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
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
name: Constraint<string>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.
InvalidNamevalidateEmail: SignupRequest -> Result<string,SignupError>Email: stringemail: Constraint<string>InvalidEmailvalidateAge: SignupRequest -> Result<int,SignupError>Age: intage: Constraint<int>InvalidAgeguard 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 payloadBoth 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 }
}
validateSignup: SignupRequest -> Result<Signup,SignupError>request: SignupRequest04-constraints_60-tutorials_10-constraint-result.md_page.SignupRequestresult: ResultBuilderThe fail-fast result { } computation expression.
AcceptedTerms: bool(|>): '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
Reified.Result.ResultModuleFail-fast helpers over the standard F# Result type.
require: bool -> Result<unit,unit>Requires an already-computed condition where there is no subject value to preserve. The condition is already computed and stands alone, so success produces Ok (). Use okIf/failIf instead when the value under test should flow through. request.AcceptedTerms |> Result.require |> Result.orError TermsNotAccepted
orError: 'error -> Result<'value,'discardedError> -> Result<'value,'error>Replaces whatever error a result carries with the supplied typed error. Ok passes through unchanged. The natural follow-up to okIf/failIf, which fail with unit precisely so the reason is chosen here: value |> Result.okIf isValid |> Result.orError MyError. Use Result.mapError instead when the existing error carries something worth keeping, as a Violation does.
TermsNotAcceptedname: stringvalidateName: SignupRequest -> Result<string,SignupError>email: stringvalidateEmail: SignupRequest -> Result<string,SignupError>age: intvalidateAge: SignupRequest -> Result<int,SignupError>Name: stringEmail: stringAge: intresult { } 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" violationSee Using constraints, Working with violations, Localization, and Result CE.