Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultSchema or generated DTO
Choose one boundary strategy per model. An attribute-validated generated record decoded with the trusted JSON path looks validated but skips its constraints. A hand-written schema needs no migration chain unless old wire shapes must remain readable.
Strategy 1: the schema is the contract
Write Schema<'model> by hand against the type your application actually uses. There is no separate wire record.
Field-level rules live on the schema (constrain, refined field types); model-level invariants live in refined
values or a private constructor. Schema.parse is the one boundary operation: it is where untrusted input becomes
a value your domain trusts.
open Reified
open Reified.SchemaDSL
open Reified.ConstraintDSL
type Signup =
{ Email: string
Age: int }
let signupSchema =
schema<Signup> {
field _.Email { constraints [ present; email ] }
field _.Age { constrain (atLeast 13) }
construct (fun email age -> { Email = email; Age = age })
}
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
FsLiveDocsGeneratedPage23_76B7E1D94D35.SignupEmail: stringstringAn abbreviation for the CLI type . Basic Types
Age: intintAn 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"
``.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: SignupConstraints: 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: SignupConstrain: 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 .
construct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
email: stringage: intUse this when you own both ends of the boundary, an HTTP request body, a config file, a form post, and the model does not need to keep reading a shape it no longer produces. This is the default. Reach for strategy 2 only when one of its two triggers applies.
→ Getting Started walks this path end to end.
Strategy 2: generate a permissive wire DTO, validate on the way into the domain
Use this when either is true:
- You do not own the wire shape, an external API, a message from another service, a format you cannot change.
- The wire shape has history, old versions must still decode, and you need a
Contract<'model>to migrate them.
[<DeriveSchema>] generates a schema from an ordinary record. For this strategy, generate it with no constraint
attributes at all. The DTO's only job is to name the fields and their wire types correctly; it makes no claim
about validity.
namespace MyApp.Wire
open Reified.DerivedSchema
[<DeriveSchema>]
type SignupWire = { Email: string; Age: int }Decode it with the trusted JSON path, Json.compile plus Json.deserialize/Json.tryDeserialize, not
Schema.parse. The two decoders
differ in exactly this: Schema.parse runs every constraint on the schema and accumulates diagnostics for a caller
who has not vetted the payload; the trusted path skips constraint checking entirely and enforces only the wire
shape, required fields and field types, because there is nothing on this DTO to check. Since the DTO carries no
attributes, the two would behave identically here; the reason to reach for the trusted path is that it says so in
the code, and it stays true if a teammate later adds an attribute to the DTO by mistake.
The generated module exposes SignupWire.schema; the hand-written equivalent below is exactly what the generator
emits for the record above. It is a complete, runnable boundary:
open Reified
open Reified.SchemaDSL
// Signup is the type from Strategy 1 above. The permissive wire schema below adds no
// constraints, so it claims nothing about validity.
let signupWireSchema : Schema<Signup> =
schema<Signup> {
field _.Email
field _.Age
construct (fun email age -> { Email = email; Age = age })
}
let wireCodec = Json.compile signupWireSchema
let toDomain (signup: Signup) : Result<Signup, string> =
match Constraint.check (Constraint.all [ Constraint.present; Constraint.email ]) signup.Email with
| Error violation -> Error $"invalid email: {Violation.render violation}"
| Ok () ->
if signup.Age >= 13 then Ok signup
else Error "age must be at least 13"
let parseWire (json: string) : Result<Signup, string> =
Json.tryDeserialize wireCodec json
|> Result.bind toDomain
parseWire """{"email":"ada@example.org","age":36}"""
// Ok { Email = "ada@example.org"; Age = 36 }
parseWire """{"email":"ada","age":11}"""
// Error "invalid email: expected an email address, but was ada"
parseWire """{"age":36}"""
// Error "JSON decode failed at $.email: missing required field"
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.
signupWireSchema: Schema<Signup>Reified.Schema`1Describes a typed value's portable structure and construction for schema interpreters. A schema records shape and construction metadata without tying that metadata to input parsing, diagnostics, validation, codecs, UI generation, or workflow execution. Primitive, collection, optional, union, refined, and record declarations all produce Schema<'value>. Record declarations use the schema<'value> { } computation expression. Each field may contain withSchema, constrain, constraints, refine, and validate operations before the declaration finishes with construct or constructResult.
FsLiveDocsGeneratedPage23_76B7E1D94D35.Signupschema: 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: SignupEmail: string``.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: intconstruct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
email: stringage: intwireCodec: JsonCodec<Signup>Reified.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
toDomain: Signup -> Result<Signup,string>signup: SignupMicrosoft.FSharp.Core.FSharpResult`2Helper type for error handling without exceptions. Choices and Results
stringAn abbreviation for the CLI type . Basic Types
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
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
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
email: Constraint<string>Requires text to match Reified's pragmatic email shape, ^[^@]+@[^@]+$. let contact : Constraint<string> = Constraint.email
ErrorRepresents an Error or a Failure. The code failed with a value of 'TError representing what went wrong.
violation: ViolationOkRepresents an OK or a Successful result. The code succeeded with a value of 'T.
(>=): 'T -> 'T -> boolStructural greater-than-or-equal The first parameter. The second parameter. The result of the comparison. 5 >= 1 // Evaluates to true 5 >= 5 // Evaluates to true [1; 5] >= [1; 6] // Evaluates to false
parseWire: string -> Result<Signup,string>json: stringtryDeserialize: JsonCodec<'model> -> string -> Result<'model,string>Deserializes a JSON string, returning decode failures as a rendered message instead of raising. Thrown when or is null.
(|>): '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.
bind: ('a -> Result<'b,'c>) -> Result<'a,'c> -> Result<'b,'c>Binds a result to the next fail-fast operation.
parseWire shows both failure kinds: Json.tryDeserialize returns the first structural wire-shape failure as a
string, and toDomain returns the first validation failure it finds. Neither accumulates path-aware diagnostics; use
Schema.parse over Data when you need every field error at once.
The real validation runs once, in toDomain, through refined values or a hand-written domain schema, exactly as it
would in strategy 1. The DTO is structural, not a second place the same rule could drift.
→ Derived Schemas covers every attribute the generator reads, including the ones this strategy deliberately does not use. → Separate Wire and Domain Models extends this into a full aggregate with controlled updates. → Versioned Contracts covers the migration chain when old wire shapes must keep decoding.
What not to do
Do not put [<Email; Present; AtLeast 13>] on a generated wire record and then decode it with Json.deserialize.
The trusted path does not read attributes, so the record looks validated in the editor and is not. Pick one:
decode it with Schema.parse instead, which makes it strategy 1's schema rather than a wire DTO, or drop the
attributes and validate in toDomain, which is strategy 2. Attributes and the trusted decoder do not combine.