Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonReified is a set of small F# libraries for .NET and Fable. You declare an invariant once — on a value, on a field, on a whole model — and the checking, the diagnostics, the codecs, the contract documents, and the test data are all read from that one declaration.
This page walks one complete transaction end to end, then widens out to the pieces it used. Everything on it
is compiled and executed on every CI run from
examples/Reified.GettingStarted;
the outputs below are that program's real output.
dotnet add package Reified.Schema
dotnet add package Reified.Schema.Json
Start with an ordinary record. Nothing about it is Reified-specific:
type Signup =
{ Email: string
Age: int
Newsletter: bool }
01-getting-started__index.md_page.SignupEmail: stringstringAn abbreviation for the CLI type . Basic Types
Age: intintAn abbreviation for the CLI type . Basic Types
Newsletter: boolboolAn abbreviation for the CLI type . Basic Types
Declare how untrusted input becomes one:
open Reified
open Reified.SchemaDSL
open Reified.ConstraintDSL
let signupSchema =
schema<Signup> {
field _.Email { constraints [ present; email ] }
field _.Age { constrain (atLeast 13) }
field _.Newsletter
construct (fun email age newsletter ->
{ Email = email; Age = age; Newsletter = newsletter })
}
ReifiedReified.SchemaDSLThe concise schema-definition vocabulary: the record computation expression, its field and constructor forms, and the collection-schema operations. Optional and opt-in, in the same shape as Reified.DataDSL and Reified.ConstraintDSL: open Reified.Schema for Schema, then open Reified.SchemaDSL for this vocabulary. There is no constraint catalogue here. One Constraint vocabulary serves direct checking, refinement, and Schema, so a field block reaches for Constraint.email or an opened Reified.ConstraintDSL exactly as standalone code does. Boundary supply is Schema-owned and stays here as mustSupply and mayOmit.
Reified.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
signupSchema: Schema<Signup>schema: SchemaCeBuilder.SchemaBuilder<'model>Record-schema computation expression.
01-getting-started__index.md_page.Signup``.ctor``: Quotations.Expr<(Signup -> string)> -> field<Signup,string>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
``.ctor``: Quotations.Expr<('model -> 'target)> -> field<'model,'target>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
_arg1: SignupEmail: stringConstraints: SchemaFieldSteps.FieldInitial<Signup,string> * Constraint<string> list -> SchemaFieldSteps.FieldConfigured<Signup,string>Adds portable constraints to the field's inferred schema in declaration order.
present: Constraint<^value>Alias for .
email: Constraint<string>Alias for .
``.ctor``: Quotations.Expr<(Signup -> int)> -> field<Signup,int>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
_arg2: SignupAge: intConstrain: SchemaFieldSteps.FieldInitial<Signup,int> * Constraint<int> -> SchemaFieldSteps.FieldConfigured<Signup,int>Adds a portable constraint to the field's current schema value.
atLeast: 'a -> Constraint<'a>Alias for .
``.ctor``: Quotations.Expr<(Signup -> bool)> -> field<Signup,bool>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
_arg3: SignupNewsletter: boolconstruct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
email: stringage: intnewsletter: boolFeed it something realistic. Data is a source-neutral input tree, so the same schema reads a form post, a
query string, JSON, or configuration:
let input =
Data.ofNameValues
[ "email", "ada@example.org"
"age", "36"
"newsletter", "true" ]
Schema.parse signupSchema input
// Ok { Email = "ada@example.org"; Age = 36; Newsletter = true }
input: DataReified.DataModuleofNameValues: (string * string) seq -> DataBuilds object-shaped data from name and value pairs.
Reified.SchemaConstruction, composition, parsing, and checking for universal schemas.
parse: Schema<'a> -> Data -> Result<'a,SchemaErrors>Parses source-neutral structured data, runs constraints and refinements, and invokes record constructors.
signupSchema: Schema<Signup>"36" arrived as text and landed as an int. No Signup exists unless every field and the constructor
succeeded, so downstream code does not have to wonder whether validation ran.
Now feed it something a real user would send — a malformed address, an age below the limit, a missing field:
let input =
Data.ofNameValues
[ "email", "ada"
"age", "11" ]
match Schema.parse signupSchema input with
| Ok signup -> register signup
| Error errors ->
for issue in SchemaErrors.toList errors do
printfn "%s: %s" (SchemaPath.format issue.Path) (SchemaError.render issue.Error)age: Expected a value at least 13, but was 11.
email: Expected an email address, but was ada.
newsletter: This value was omitted.
Every independent field is checked, so one parse reports every problem rather than the first. The paths come from the structure of the declaration — application code never repeats field names alongside the checks. Nobody wrote those three sentences: each one is rendered from the rule that failed.
And now the declaration pays for itself. The same signupSchema, read by a different interpreter, is a JSON
codec:
open Reified.Schema.Json
let codec = Json.compile signupSchema // compile once, typically at startup
Json.serialize codec { Email = "ada@example.org"; Age = 36; Newsletter = true }
// {"email":"ada@example.org","age":36,"newsletter":true}
ReifiedSchemaJsoncodec: JsonCodec<Signup>Reified.Schema.Json.JsonFunctions for compiling and running JSON codecs over built model schemas.
compile: Schema<'model> -> JsonCodec<'model>Compiles a completed schema into a reusable JSON codec. Compile once per schema, typically at startup, and reuse the codec for every value. Constructor-last object schemas retain a typed record plan, including checked constructors. Constructor failures surface as during decoding. Thrown when is null. Thrown when is incomplete. open Reified.SchemaDSL open Reified.ConstraintDSL type Customer = { Name: string; Email: string } let customerSchema = schema<Customer> { field _.Name { constrain present } field _.Email { constraints [ present; email ] } construct (fun name email -> { Name = name; Email = email }) } let customer = { Name = "Ada"; Email = "ada@example.org" } let codec = Json.compile customerSchema let json = Json.serialize codec customer let roundTripped = Json.deserialize codec json
signupSchema: Schema<Signup>serialize: JsonCodec<'model> -> 'model -> stringSerializes a trusted model to a JSON string through a compiled codec. Thrown when is null.
Email: stringAge: intNewsletter: boolThere is no second description of the wire shape to keep in step, and no runtime reflection: the codec is compiled from the schema's typed field plan, so it works under NativeAOT, trimming, and Fable.
That is the whole idea. The rest of this page is the same idea at smaller and larger scales.
The problem it solves
One rule — "an age is at least 13" — usually ends up written four times: in the parser that reads the request, in the validator that guards the domain, in the form that shows the message, and in the test that builds a fixture. They start identical and drift. When they drift, the parser accepts what the validator rejects, or the form shows a message no code enforces.
Reified's answer is to make the rule a value. A rule you can inspect can be executed by a checker, explained by a renderer, exported to JSON Schema or OpenAPI, and sampled by a generator — from one declaration.
One rule on one value
The smallest version needs no schema at all. Install Reified.Constraint:
open Reified
let retryCount : Constraint<int> = Constraint.between 0 10
3 |> Constraint.check retryCount
// Ok ()
42
|> Constraint.check retryCount
|> Result.mapError Violation.render
// Error "expected a value between 0 and 10, but was 42"
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
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"
Nobody wrote that failure sentence separately. A Constraint carries its own description, and a Violation
carries the rule that failed and the offending value as data — so the message cannot fall out of step with the
check, and it can be localized or reformatted without touching the rule.
Attach the rule to a type
A constraint holds wherever you remember to run it. A refinement holds for every value of a type, because the only way to build one is through the check:
open Reified.Refinements
Refine.nonBlankString "Ada" // Ok (NonBlankString "Ada")
Refine.nonBlankString " " // Error ...
ReifiedRefinementsReified.Refinements.RefineModuleSmart constructors for built-in refined values, and the refinements behind them. Text, Character, Collection, and Choice are nested here rather than declared beside Refine. Each names a concept general enough to shadow something a caller already has in scope, and none of them is a companion to a type this package exports, so there is nothing to gain from the type-and-module pairing that keeps NonBlankString and DistinctList at the top level.
nonBlankString: string -> Result<NonBlankString,Violation>Downstream code takes NonBlankString and stops re-checking. Your own domain types work the same way —
CustomerId, Email, WorkspaceName — each defined over a constraint and constructed through it.
Back to the model
Refined types are field types, so the schema from the top of this page absorbs them without extra syntax:
type Registration =
{ Owner: NonBlankString
Seats: int }
let registrationSchema =
schema<Registration> {
field _.Owner
field _.Seats { constrain (atLeast 1) }
construct (fun owner seats -> { Owner = owner; Seats = seats })
}
01-getting-started__index.md_page.RegistrationOwner: NonBlankStringReified.Refinements.NonBlankStringA string that is not null, empty, or whitespace.
Seats: intintAn abbreviation for the CLI type . Basic Types
registrationSchema: Schema<Registration>schema: SchemaCeBuilder.SchemaBuilder<'model>Record-schema computation expression.
``.ctor``: Quotations.Expr<(Registration -> NonBlankString)> -> field<Registration,NonBlankString>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
_arg1: Registration``.ctor``: Quotations.Expr<(Registration -> int)> -> field<Registration,int>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
``.ctor``: Quotations.Expr<('model -> 'target)> -> field<'model,'target>Declares a field, deriving its camel-cased wire name from the property getter. type Signup = { Email: string } field (fun (s: Signup) -> s.Email) // wire name "email"
_arg2: RegistrationConstrain: SchemaFieldSteps.FieldInitial<Registration,int> * Constraint<int> -> SchemaFieldSteps.FieldConfigured<Registration,int>Adds a portable constraint to the field's current schema value.
atLeast: 'a -> Constraint<'a>Alias for .
construct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
owner: NonBlankStringseats: intThe Owner field carries no rules of its own: every refined type has exactly one schema, and the field
resolves it from the type. Use a constraint on the field when the rule belongs to this boundary; use a
refined type when it belongs to the domain.
Everything else is derived from that declaration
The schema is a value, so parsing is only one of the things that can read it:
JsonSchema.generate signupSchema
// {"type":"object",
// "properties":{"email":{"type":"string", …},
// "age":{"type":"integer","minimum":13},
// "newsletter":{"type":"boolean"}},
// "required":["email","age","newsletter"]}
Reified.JsonSchemaGenerates JSON Schema documents from built model schemas. The generator is a pure interpreter over descriptions: it lowers shapes, declared formats, and portable constraint metadata to JSON Schema keywords without parsing input, running checks, or constructing models. One schema declaration therefore drives parsing, validation, and the published contract. Lowering rules: primitives map to type (with format for dates, date-times, and uuids), refined values lower to their underlying primitive representation, nested models to object with properties and required, collections to array with items, maps to object with additionalProperties, and tagged unions to oneOf with a const-constrained discriminator property per case. Constraint metadata lowers to minLength, maxLength, pattern, enum, minimum/maximum (and exclusive variants), multipleOf, minItems/maxItems, and uniqueItems; constraints without a JSON Schema equivalent, such as trimmed, are skipped. Default-value metadata attached with Schema.withDefault lowers to default.
generate: Schema<'model> -> stringGenerates a compact JSON Schema document from any completed schema declaration. The record, primitive, collection, union, or other completed schema to lower. Thrown when is null. open Reified.SchemaDSL open Reified.ConstraintDSL type Customer = { Name: string; Email: string } let customerSchema = schema<Customer> { field _.Name { constrain present } field _.Email { constraints [ present; email ] } construct (fun name email -> { Name = name; Email = email }) } let document = JsonSchema.generate customerSchema // {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{...},"required":[...]}
signupSchema: Schema<Signup>"minimum": 13 was not written twice — it is the atLeast 13 from the declaration, read for a different
purpose. The same declaration does four more jobs: checking a value you already hold, accepting payloads from
older versions, describing the model to a form, and generating test data that obeys the rules.
Failures are ordinary F# values
There is no exception model and no framework result type. Schema.parse returns
Result<'model, SchemaErrors>; a value check returns Result<'value, Violation>. Both errors are data you can
match on, group by path, translate, or serialize into a problem-details response.
Reified.Result adds the composition — result { } for fail-fast sequencing, accumulating
builders for collecting every error at once — over the standard Result type rather than replacing it.
The words this documentation uses
Three operations sound similar and are not. The rest of the documentation uses these words precisely:
| Word | Operation | Example |
|---|---|---|
| parse | Change one representation into another. Text becomes a typed value. | Parse.int "42" |
| The same move over a whole tree of untrusted input, accumulating every failure with its path. | input \|> Schema.parse signupSchema |
|
| check | Test a value you already hold against a rule. The value does not change. | Constraint.check (atLeast 13) age |
| refine | Give a checked value a type that carries the rule from then on, so nothing downstream re-checks it. | ContactEmail.create raw |
"Validation" is the everyday word for all of this, and the documentation uses it that way when talking about the area as a whole. When the distinction matters, it will be one of the three above instead.
Where to go next
Continue with Schema for structured boundary models, or choose another focused capability from the library map.
Installing
dotnet add package Reified
That installs the complete runtime set. Every package is also independently installable if you want one capability on its own — see Packages and platforms for the list, what each one gives you, and which run on Fable as well as .NET.