Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonQuickstart
A Schema<'model> is one declaration of a structured boundary. It names fields, gives each a value schema, runs
constraints and refinements, accumulates failures with paths, and calls your constructor only once the fields succeed.
The same declaration drives several interpreters, so parsing, JSON serialization, JSON Schema generation, and metadata inspection stay in step with no second source of truth.
This page builds one model up in four stages. Each stage adds one idea and shows what the interpreters do with it.
dotnet add package Reified.Schema
open Reified
open Reified.SchemaDSL
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.
1. Plain fields
Start with primitives and nothing else:
type Signup =
{ Email: string
Age: int
Newsletter: bool }
let signupSchema =
schema<Signup> {
field _.Email
field _.Age
field _.Newsletter
construct (fun email age newsletter ->
{ Email = email; Age = age; Newsletter = newsletter })
}
08-schema_01-quickstart.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
signupSchema: Schema<Signup>schema: SchemaCeBuilder.SchemaBuilder<'model>Record-schema computation expression.
``.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"
_arg1: Signup``.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: Signup``.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: Signupconstruct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
email: stringage: intnewsletter: boolfield _.Email gives the getter and nothing more. The field's value schema comes from its type — string resolves
Schema.text, int resolves Schema.int, bool resolves Schema.bool — and the wire name is the camelCased
property name. construct receives the fields in declaration order; the compiler checks its argument types and its
result.
Pass a wire name explicitly when it differs from the property: fieldAs "email_address" _.Email.
Deriving the name from _.Email uses a quotation. That compiles on .NET and on the Fable targets that support
quotations, including JavaScript; reach for fieldAs on Fable's Rust and PHP targets. See
SchemaDSL and Compiler-Directed, AOT, and Fable.
Parse
Data is a source-neutral input tree. The same schema reads form posts, CLI arguments, JSON, and configuration —
see Input Sources.
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" arrives as text and lands as int. Decoding a primitive from its serialized form is part of the field's value
schema.
No Signup is produced unless every field succeeds and the constructor succeeds.
Read the failures
Independent fields are all interpreted, so one parse reports every problem rather than the first:
match Schema.parse signupSchema input with
| Ok signup -> save signup
| Error errors ->
for issue in SchemaErrors.toList errors do
printfn "%s: %s" (SchemaPath.format issue.Path) (SchemaError.render issue.Error)Input missing newsletter and carrying "age": "not-a-number":
age: Expected int format.
newsletter: This value was omitted.
Paths come from the structure of the declaration. Application code never repeats field names, nested object names, list indexes, or map keys alongside separate validation expressions. For nesting and collections, see Nested Models And Collections.
Serialize
Reified.Schema.Json compiles the same declaration into a JSON codec. It uses no runtime reflection, so it works under
NativeAOT, trimming, and Fable.
dotnet add package Reified.Schema.Json
open Reified.Schema.Json
let codec = Json.compile signupSchema
Json.serialize codec signup
// {"email":"ada@example.org","age":36,"newsletter":true}
Json.deserialize codec jsonCompile the codec once and reuse it. Json.compile does the work up front; serialize and deserialize are the hot
path. See JSON Codecs for buffers, streams, and decode diagnostics.
The other interpreters
The same signupSchema also drives:
JsonSchema.generate signupSchema
// {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object",
// "properties":{"email":{"type":"string"},"age":{"type":"integer"},
// "newsletter":{"type":"boolean"}},
// "required":["email","age","newsletter"]}
Inspect.model signupSchema
// finite metadata: field names, shapes, constraints — no execution
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>Reified.InspectThe inspection API over built schemas and value schemas. Inspect is the entry point for non-validation interpreters: JSON Schema generation, documentation, UI metadata, and codec planning all start from the same descriptions. The returned trees are plain immutable data — inspecting them never parses input, runs checks, or constructs models.
model: Schema<'model> -> ModelDescriptionDescribes a built model schema as inspectable field metadata. The built model schema to describe. Thrown when is null. Thrown when is not a completed model schema. let description = Inspect.model customerSchema let names = description.Fields |> List.map _.Name
| Input | Interpreter | Result |
|---|---|---|
Data |
Schema.parse |
model or SchemaErrors |
| an existing typed value | Schema.check |
the same value or SchemaErrors |
| schema | Json.compile |
reusable JSON codec |
| schema | JsonSchema.generate |
JSON Schema document |
| schema | Inspect.model |
metadata without execution |
versioned Data |
Contract.parse |
current model or ContractError |
Schema.check is for values that did not arrive as Data — a record literal, a database mapper's output, an import.
It runs the same field rules and calls the same constructor:
Schema.check signupSchema existingValueInspect.model is what forms, admin UIs, and documentation generators read.
Everything after this point is added to the declaration once and shows up in all of these interpreters.
2. Refined fields
Signup.Email is a string, so nothing stops { Email = ""; Age = 36; Newsletter = true }. A refined type moves that
guarantee into the model, where it holds no matter how the value was built.
Reified.Refinements ships the common ones. Use them as field types directly:
open Reified.Refinements
type Registration =
{ Owner: NonBlankString
Seats: int
Aliases: NonEmptyList<NonBlankString> }
let registrationSchema =
schema<Registration> {
field _.Owner
field _.Seats
field _.Aliases
construct (fun owner seats aliases ->
{ Owner = owner; Seats = seats; Aliases = aliases })
}
ReifiedRefinements08-schema_01-quickstart.md_page.RegistrationOwner: NonBlankStringReified.Refinements.NonBlankStringA string that is not null, empty, or whitespace.
Seats: intintAn abbreviation for the CLI type . Basic Types
Aliases: NonEmptyList<NonBlankString>Reified.Refinements.NonEmptyList`1A list that contains at least one item. The case is public: non-emptiness is carried by the representation rather than by a checked constructor, so head, last, reduce, min, and max are total and pattern matching is available to callers.
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"
_arg2: Registration``.ctor``: Quotations.Expr<(Registration -> NonEmptyList<NonBlankString>)> -> field<Registration,NonEmptyList<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"
_arg3: Registrationconstruct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
owner: NonBlankStringseats: intaliases: NonEmptyList<NonBlankString>The fields stay bare. Every refined type has exactly one schema, so the field resolves it from the type the same way
string resolved Schema.text. Rules that need a parameter — a length range, a pattern — are constraints rather
than types, and go on the field as their own line: field _.Name { constrain (Constraint.lengthBetween 2 80) }.
NonEmptyList<NonBlankString> composes: the outer refinement resolves, and so does the item.
It flows through
A refinement carries its constraints, so the interpreters see them:
JsonSchema.generate registrationSchema
// "owner": {"type":"string"}
// "seats": {"type":"integer","exclusiveMinimum":0}
// "aliases": {"type":"array","items":{"type":"string"},"minItems":1}
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":[...]}
registrationSchema: Schema<Registration>Inspect.model registrationSchema
// owner -> [ "present" ]
// seats -> [ "greaterThan" ]
// aliases -> [ "minLength" ]
Reified.InspectThe inspection API over built schemas and value schemas. Inspect is the entry point for non-validation interpreters: JSON Schema generation, documentation, UI metadata, and codec planning all start from the same descriptions. The returned trees are plain immutable data — inspecting them never parses input, runs checks, or constructs models.
model: Schema<'model> -> ModelDescriptionDescribes a built model schema as inspectable field metadata. The built model schema to describe. Thrown when is null. Thrown when is not a completed model schema. let description = Inspect.model customerSchema let names = description.Fields |> List.map _.Name
registrationSchema: Schema<Registration>Failures arrive on the right paths:
owner: This value must be present.
seats: Must be greater than 0; got 0.
aliases: Length must be at least 1; got 0.
Nothing in the schema declares any of this. A refined type means the same thing wherever it appears, and the generated JSON Schema, the inspection metadata, and the parse diagnostics all read that one definition. Each check runs once, at the layer that owns it.
Your own domain types participate the same way. See Refined Schemas for defining Email or
WorkspaceName and contributing a canonical schema.
3. Constraints on fields
A refinement holds for every value of its type. A constraint holds at one boundary. Use a constraint when the rule belongs to this form rather than to the domain type:
type Profile =
{ DisplayName: string
Age: int }
let profileSchema =
schema<Profile> {
field _.DisplayName {
constraints [ present; maxLength 40 ]
}
field _.Age {
constrain (between 13 120)
}
construct (fun displayName age -> { DisplayName = displayName; Age = age })
}A field block is the expanded form of field _.DisplayName. constrain adds one; constraints adds a list.
Constraints reach the interpreters just as refinements do:
JsonSchema.generate profileSchema
// "displayName": {"type":"string","maxLength":40}
// "age": {"type":"integer","minimum":13,"maximum":120}displayName: This value must be present.
age: Must be between 13 and 120; got 9.
A constraint preserves the value's type — maxLength 40 on a string field leaves a string field. That is the
difference from refine, which changes the type and is what makes the guarantee durable.
Which to reach for:
- The rule is true of every value of the type — put it in the refinement, and every construction path enforces it.
- The rule is true only at this boundary — put it in the field block, where a reader can see it applies here.
Schema will not take metadata without an executable check behind it, so what an inspector reports is always what parsing enforces. Constraint names come from Constraints; see Refined Schemas for application-defined constraints.
4. A private model behind a checked constructor
Stages 2 and 3 cover rules about one field. A rule between fields — a booking's start must not follow its end — has nowhere field-local to live, and no field type can carry it.
Make the representation private so the only way to build the type runs the rule:
type Booking =
private
{ Guest: NonBlankString
Start: DateOnly
End: DateOnly }Now { Guest = g; Start = s; End = e } will not compile outside the defining module, and constructResult becomes the
one entrance:
module Booking =
let create (draft: BookingDraft) =
if draft.Start <= draft.End then
Ok { Guest = draft.Guest; Start = draft.Start; End = draft.End }
else
Error "Start must not be after end."
let guest (booking: Booking) = booking.Guest
let start (booking: Booking) = booking.Start
let finish (booking: Booking) = booking.End
let schema =
schema<Booking> {
fieldAs "guest" guest
fieldAs "start" start
fieldAs "end" finish
constructResult (fun guest start finish ->
create { Guest = guest; Start = start; End = finish })
}construct becomes constructResult, which returns Result and can reject. Fields use accessor functions because
_.Start needs the representation.
A reversed range now fails at the model, not at a field, so the diagnostic carries no field path:
: Start must not be after end.
The draft
A private record costs record syntax: callers lose { Guest = g; ... } and { booking with End = e }, and a positional
create guest start finish loses the names that make call sites readable.
A draft is a public record that exists to be assembled and edited freely, with create as the one way across:
type BookingDraft =
{ Guest: NonBlankString
Start: DateOnly
End: DateOnly }
module Booking =
let toDraft (booking: Booking) : BookingDraft =
{ Guest = booking.Guest; Start = booking.Start; End = booking.End }Construction keeps its field names:
Booking.create { Guest = guest; Start = arrival; End = departure }Edits drop to the draft, use ordinary with, and come back through the same constructor:
let shift days booking =
let draft = Booking.toDraft booking
Booking.create { draft with Start = draft.Start.AddDays days; End = draft.End.AddDays days }The draft is not a hole in the guarantee. A BookingDraft proves nothing; only Booking.create and
Schema.parse Booking.schema produce a Booking, and both run the same rule. Skipping it means editing the module
that owns the representation — a visible act, rather than a quiet record literal elsewhere in the codebase.
Every gated update returns Result. That is the real cost of a cross-field invariant: an edit can break the
relationship, so an infallible with on the checked type would be exactly the bypass this stage closes.
Where to go next
The four stages are a ladder, not a target. Take the lowest rung that prevents a real problem:
- Plain fields — the schema is the admission decision.
- Refined fields — field-local invariants hold everywhere, and the record stays public with
withintact. - Field constraints — boundary-specific rules, visible where they apply.
- Private model and draft — relationships between fields.
- Construction Guarantees — what each rung does and does not promise.
- SchemaDSL — the full declaration vocabulary.
- Derived Schemas — generate that declaration from an attributed F# wire record.
- Field Blocks and Plain Functions — a field block read as ordinary functions over one
Schema. - Refined Schemas — your own domain types as fields.
- Tutorials — a signup form, nested models and collections, and metadata inspection.
- Redisplay And Field Errors — failed parses that keep the user's input.
- Versioned Contracts — evolving the wire format without freezing the domain model.