Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultReified vs FluentValidation
FluentValidation validates objects that already exist. Reified's Schema.parse either constructs a value whose
declared boundary rules passed or reports path-aware issues; it never hands back a partially checked object.
The Model Difference
A FluentValidation validator receives a constructed object and reports rule failures:
public class CustomerValidator : AbstractValidator<Customer>
{
public CustomerValidator()
{
RuleFor(c => c.Name).NotEmpty().MaximumLength(80);
RuleFor(c => c.Age).InclusiveBetween(13, 120);
}
}
The Customer had to be constructed first, usually by a model binder filling public setters with whatever arrived.
Between construction and validation (and anywhere a code path forgets to call the validator) an invalid Customer
exists and can leak.
A Reified schema owns construction. Parsing either produces a trusted model or path-aware issues; there is no intermediate invalid object:
open Reified
open Reified.SchemaDSL
type Customer = { Name: string; Age: int }
let customerSchema =
schema<Customer> {
field _.Name {
constrain (Constraint.maxLength 80)
}
field _.Age {
constrain (Constraint.between 13 120)
}
construct (fun name age -> { Name = name; Age = age })
}
let raw =
Data.ofNameValues
[ "name", "Ada"
"age", "36" ]
match Schema.parse customerSchema raw with
| Ok customer -> printfn "accepted %s" customer.Name // a Customer returned by Schema.parse passed the declared rules
| Error errors -> printfn "%A" errors
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.
FsLiveDocsGeneratedPage24_F2D257DD2F93.CustomerName: stringstringAn abbreviation for the CLI type . Basic Types
Age: intintAn abbreviation for the CLI type . Basic Types
customerSchema: Schema<Customer>schema: SchemaCeBuilder.SchemaBuilder<'model>Record-schema computation expression.
``.ctor``: Quotations.Expr<(Customer -> string)> -> field<Customer,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: CustomerConstrain: SchemaFieldSteps.FieldInitial<Customer,string> * Constraint<string> -> SchemaFieldSteps.FieldConfigured<Customer,string>Adds a portable constraint to the field's current schema value.
Reified.ConstraintModuleCreates, executes, composes, and inspects constraints.
maxLength: int -> Constraint<^value>Requires text or a collection to have at most the supplied size. let summary : Constraint<string> = Constraint.maxLength 280
``.ctor``: Quotations.Expr<(Customer -> int)> -> field<Customer,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: CustomerConstrain: SchemaFieldSteps.FieldInitial<Customer,int> * Constraint<int> -> SchemaFieldSteps.FieldConfigured<Customer,int>Adds a portable constraint to the field's current schema value.
between: 'value -> 'value -> Constraint<'value>Requires a value inside the supplied inclusive bounds. let retryCount : Constraint<int> = Constraint.between 0 10
construct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
name: stringage: intraw: 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.
OkRepresents an OK or a Successful result. The code succeeded with a value of 'T.
customer: Customerprintfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
ErrorRepresents an Error or a Failure. The code failed with a value of 'TError representing what went wrong.
errors: SchemaErrorsThe Name rule shown is only Constraint.maxLength 80, which admits an empty string; add Constraint.present when
a non-blank name is required. And because Customer is an ordinary public record, code outside the boundary can still
construct { Name = ""; Age = 0 } directly — the "cannot exist invalid" guarantee applies to values Schema.parse
returns, not to every value of the type.
With refined field types (such as an Email with a private constructor), downstream code need not check whether the fields were validated.
Rules Are Data, Not Just Code
FluentValidation rules are lambdas inside a class: they can run, but nothing else can read them. A Reified schema is
inspectable data: its structure — the declared fields, getters, and constructor — is what Inspect.model reads for UI
metadata and Json.compile compiles into a JSON codec, while the constraints each field carries are what
JsonSchema.generate exports and Schema.parse enforces. So the same declaration produces the JSON Schema/OpenAPI
contract, UI metadata, a compiled JSON codec, and redisplayable form errors. With FluentValidation, each of those is a
separate artifact to keep in sync by hand.
The compiled codec enforces JSON structure and required fields, not the field constraints: use it for trusted producers,
and Schema.parse when the value rules must hold for untrusted input.
Where FluentValidation Fits Better
- Large existing C# codebases already organized around DTOs, model binding, and validator classes.
- Validation of objects you genuinely do not construct (third-party types, EF entities mid-flight).
- Teams that want C#-first fluent syntax rather than F# declarations.
Schema.check schema model re-checks an existing value against the same field schemas, then re-invokes the record's
constructor with the checked field values. It returns the original value on success, or SchemaErrors with the same
paths as parsing.
Side By Side
| Concern | FluentValidation | Reified |
|---|---|---|
| Invalid object exists? | Yes, until validated | Not for values Schema.parse returns |
| Rules readable as data | No (lambdas in classes) | Yes (Constraint metadata) |
| OpenAPI/JSON Schema | Separate annotations | Generated from the same declaration |
| Error paths | Property names via expressions | Structural paths (contacts[1].value) |
| Reflection | Expression trees + reflection | None; AOT/trimming/Fable-safe |