Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultJSON Codecs
The Json module turns the schema you already declared into a compiled JSON codec, so trusted serialization and
boundary parsing come from one declaration. It is included in the Reified.Schema package.
Reified has two paths for JSON, and they exist because they optimize for different things:
- Boundary parsing —
Data+Schema.parse: for untrusted input. It runs constraint metadata, accumulates path-aware diagnostics, and keeps the structured data for redisplay. - Trusted path —
Json.compile+Json.serialize/Json.deserialize: for payloads whose producer you trust, such as internal services, storage, caches, and queues. It enforces the wire shape and required fields, skips constraint checking, and runs about 4x faster than the boundary path with a fraction of the allocations (see the benchmarks).
Compile Once, Reuse Everywhere
open Reified
open Reified
open Reified.SchemaDSL
type Address =
{ Street: string; City: string }
static member Schema(_: Address) : Schema<Address> =
schema<Address> {
field _.Street
field _.City
construct (fun street city -> { Street = street; City = city })
}
type Customer =
{ Name: string
Age: int
Address: Address }
let customerSchema =
schema<Customer> {
field _.Name
field _.Age
field _.Address
construct (fun name age address -> { Name = name; Age = age; Address = address })
}
let codec = Json.compile customerSchema // compile once, typically at startup
let json = Json.serialize codec { Name = "Ada"; Age = 36; Address = { Street = "12 Analytical Way"; City = "London" } }
// {"name":"Ada","age":36,"address":{"street":"12 Analytical Way","city":"London"}}
let customer = Json.deserialize codec json
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.
02-schema_50-json-codecs.md_page.AddressStreet: stringstringAn abbreviation for the CLI type . Basic Types
City: stringSchema: Address -> Schema<Address>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.
schema: SchemaCeBuilder.SchemaBuilder<'model>Record-schema computation expression.
``.ctor``: Quotations.Expr<(Address -> string)> -> field<Address,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"
_arg2: Address_arg3: Addressconstruct: 'constructor -> SchemaCeBuilder.ConstructorStep<'model,'constructor>Closes a record schema with a total constructor.
street: stringcity: string02-schema_50-json-codecs.md_page.CustomerName: stringAge: intintAn abbreviation for the CLI type . Basic Types
Address: AddresscustomerSchema: Schema<Customer>``.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"
_arg1: Customer``.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: Customer``.ctor``: Quotations.Expr<(Customer -> Address)> -> field<Customer,Address>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: Customername: stringage: intaddress: Addresscodec: JsonCodec<Customer>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
json: stringserialize: JsonCodec<'model> -> 'model -> stringSerializes a trusted model to a JSON string through a compiled codec. Thrown when is null.
customer: Customerdeserialize: JsonCodec<'model> -> string -> 'modelDeserializes a JSON string to a trusted model through a compiled codec. Thrown when or is null. Thrown when the JSON does not match the schema's wire shape.
Json.compile walks the typed record plan retained when the object shape closes and emits a direct plan: ordered field
descriptors, cached UTF-8 wire-name bytes, typed field decoders, and the original curried constructor applied without
boxing. Decode first follows the canonical field order emitted by Reified without field dispatch or per-field slots. A
reordered object, alias, omitted field, or unknown field transparently restarts through the general unordered decoder;
callers do not select a mode. Everything is compiler-directed: there is no runtime reflection at codec-compile time or
per value, so the codec is AOT- and trimming-safe by construction. Applying a curried constructor of ten or more fields needs
NativeAOT generic-cycle limits above the compiler defaults; the Reified.Schema package raises them for you, as
Compiler-Directed, AOT, and Fable explains.
Every Schema Shape Is Supported
Refined values encode as their raw representation and are reconstructed on decode; nested models, collections, and tagged unions follow the same wire shapes the input parser reads:
// A union field {"type":"card","value":{...}} round-trips through the same discriminator convention.
let orderCodec = Json.compile orderSchemaString-Like Map Keys
JSON object property names are strings, but the model key does not have to be. Use Schema.mapWithKey when a key type
has a total conversion to and from a property name:
type LocaleTag = LocaleTag of string
let localizedText =
Schema.mapWithKey LocaleTag (fun (LocaleTag value) -> value) Schema.text
02-schema_50-json-codecs.md_page.LocaleTagLocaleTagstringAn abbreviation for the CLI type . Basic Types
localizedText: Schema<Map<LocaleTag,string>>Reified.SchemaConstruction, composition, parsing, and checking for universal schemas.
mapWithKey: (string -> 'a) -> ('a -> string) -> Schema<'b> -> Schema<Map<'a,'b>>Describes a map whose key type converts to and from object property names. Converts an input property name to a model key. Converts a model key to an output property name. The schema for each map value. A schema for a map with converted keys. type LocaleTag = LocaleTag of string let schema = Schema.mapWithKey LocaleTag (fun (LocaleTag value) -> value) Schema.text
_arg1: LocaleTagvalue: stringtext: Schema<string>Describes text input.
Boundary parsing and compiled JSON codecs use the same conversion, including under Fable. Build derivation infers this schema for a map keyed by a transparent single-case string union.
Decode Failures Carry Paths
Decoding trusted input can still meet malformed payloads. Failures raise JsonCodecException with a schema-relative
path, or use tryDeserialize for a Result:
match Json.tryDeserialize codec """{"name":"Ada","age":"not-a-number"}""" with
| Ok customer -> customer
| Error message -> failwith message // JSON decode failed at $.age: expected digit
Reified.JsonFunctions for compiling and running JSON codecs over built model schemas.
tryDeserialize: 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.
codec: JsonCodec<Customer>OkRepresents an OK or a Successful result. The code succeeded with a value of 'T.
customer: CustomerErrorRepresents an Error or a Failure. The code failed with a value of 'TError representing what went wrong.
message: stringfailwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
The codec reports the first structural failure and stops. When you need every problem reported with redisplayable input — a form, a public API — that is boundary parsing's job:
// Boundary parsing: complete diagnostics for untrusted input.
let parsed = Schema.parse customerSchema (Data.ofJsonDocument document)Bytes In, Bytes Out
Json.serializeBytes and Json.deserializeBytes avoid the string conversion when the payload already lives as UTF-8
bytes, which is the faster path for network and storage boundaries:
let bytes = Json.serializeBytes codec customer
let roundTripped = Json.deserializeBytes codec bytes
bytes: byte arrayReified.JsonFunctions for compiling and running JSON codecs over built model schemas.
serializeBytes: JsonCodec<'model> -> 'model -> byte arraySerializes a trusted model to UTF-8 JSON bytes through a compiled codec. Thrown when is null.
codec: JsonCodec<Customer>customer: CustomerroundTripped: CustomerdeserializeBytes: JsonCodec<'model> -> byte array -> 'modelDeserializes UTF-8 JSON bytes to a trusted model through a compiled codec. Thrown when or is null. Thrown when the JSON does not match the schema's wire shape.
Formatting The Output
serialize, serializeBytes, and serializeToStream emit compact JSON. For an indented string — logs, fixtures,
config files a human reads — use serializeIndented:
let pretty = Json.serializeIndented codec customer
// {
// "name": "Ada",
// "age": 36,
// "address": {
// "street": "12 Analytical Way",
// "city": "London"
// }
// }
pretty: stringReified.JsonFunctions for compiling and running JSON codecs over built model schemas.
serializeIndented: JsonCodec<'model> -> 'model -> stringSerializes a trusted model to an indented JSON string with two-space nesting. Shorthand for serializeWith indented. For indented bytes or a stream, use serializeBytesWith indented or serializeToStreamWith indented. Thrown when is null.
codec: JsonCodec<Customer>customer: CustomerEvery other cosmetic knob lives on JsonWriteOptions, passed as a configuring function to serializeWith /
serializeBytesWith / serializeToStreamWith (the same shape as Schema.parseWith). Json.defaults is the
unmodified compact value, and Json.indented is a ready-made transform, so serializeWith id equals serialize and
serializeWith Json.indented equals serializeIndented:
let ascii = Json.serializeWith (fun o -> { o with AsciiOnly = true }) codec customer
// non-ASCII scalars become \uXXXX
let forHtml = Json.serializeWith (fun o -> { o with Indent = JsonIndent.Spaces 4; EscapeHtml = true }) codec customer
// 4-space indent; < > & ' + and U+2028/U+2029 escaped for embedding in a <script> element
ascii: stringReified.JsonFunctions for compiling and running JSON codecs over built model schemas.
serializeWith: (JsonWriteOptions -> JsonWriteOptions) -> JsonCodec<'model> -> 'model -> stringSerializes a trusted model to a JSON string, configuring the writer through . Indentation and re-escaping run as one linear pass over the compact output, so a non-default configuration costs roughly one extra copy of the payload. serializeWith id equals serialize. Thrown when is null.
o: JsonWriteOptionsReified.JsonWriteOptionsCosmetic options for the JSON writer. Every combination produces JSON a conformant reader parses back to the same model. Wire-shape choices — field names, union representation, whether an absent field is omitted or written as null — belong on the schema, not here. Pass a configuring function to ; the unmodified value is , which matches .
AsciiOnly: boolEscape every non-ASCII scalar as \uXXXX so the output is pure ASCII.
codec: JsonCodec<Customer>customer: CustomerforHtml: stringIndent: JsonIndentIndentation for nested levels. is compact.
EscapeHtml: boolAlso escape <, >, &, ', +, and U+2028/U+2029 for safe embedding in HTML or a pre-ES2019 <script> element.
Indentation and re-escaping run as one linear pass over the compact output, so a non-default configuration costs
roughly one extra copy of the payload; the compact functions stay on the direct path. Json.reindent applies the
same pass to any JSON string, codec-produced or not, which is handy for pretty-printing a payload in a log line.
The options are strictly cosmetic — every combination parses back to the same model. Wire-shape decisions (field
names, union representation, whether an absent field is omitted or written as null) belong on the schema.
What The Codec Does Not Do
- It does not run constraint metadata such as
maxLengthorbetween— those belong to boundary parsing and validation. A value that only ever passes through trusted systems does not pay for checks it already passed. - Checked constructors from
constructResultstill run, so intrinsic cross-field invariants hold on the trusted path; their errors surface asJsonCodecException.
From C#
Consume-don't-author: F# declares the schema, C# compiles the codec, parses, and reads diagnostics. Every Json.*
function takes plain positional arguments, so it calls as an ordinary static method with no FSharpFunc conversion:
using Reified.Schema;
using Reified;
JsonCodec<Customer> codec = Json.compile(customerSchema);
string json = Json.serialize(codec, customer);
Customer roundTripped = Json.deserialize(codec, json);
// Failures raise JsonCodecException instead of a Result, or use tryDeserialize:
var attempt = Json.tryDeserialize(codec, json); // FSharpResult<Customer, string>
serializeToStream and deserializeStreamAsync (both async/Task-based) are also plain static calls, so they work
directly against HttpContext.Response.Body / Request.Body in an ASP.NET Core handler.
Next
- Serve the same declaration as a contract with
JsonSchema.generate. - See the two paths together in the runnable minimal API sample.