Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result

JSON 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 values that were already admitted by your own boundary or produced by a service under your control. Trust is a property of the producer and the value's admission history, not of the transport: storage, caches, and queues can hold stale or externally written bytes, so treat them as trusted only when every writer is trusted and wrote only admitted values. The codec 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

Verified
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
Requires & types
Requires: open Reified; open Reified.SchemaDSL (packages: Reified.Schema)
val customerSchema: Schema<Customer>
val codec: JsonCodec<Customer>
val json: string
val customer: Customer

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. Any layout that does not match that order — a reordered object, an alias, an omitted required field, or an unknown field — makes the canonical attempt return no match, and the codec transparently restarts through the general unordered decoder; callers do not select a mode. The general decoder matches canonical names and aliases, skips unknown fields (they are never an error), and reports a missing required field only after the whole object has been read. 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.

Compile once, reuse safely

A compiled codec is an immutable pair of encode and decode closures. Json.compile builds a fresh plan on each call, so compile once at startup and share the returned JsonCodec<'model> across requests and threads; the per-decode field slots the general decoder uses are allocated per call, never stored on the codec. A Schema value is immutable too, so Schema.parse and Schema.check are safe to run concurrently over the same schema.

Recursive shapes closed with Schema.defer use a memoized thunk: the deferred declaration is forced once, on first use, and the resolved definition is reused. F#'s lazy memoization is thread-safe, so the first concurrent uses of a deferred recursive schema all observe the same resolved definition rather than racing to build it.

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:

Fragment
// A union field {"type":"card","value":{...}} round-trips through the same discriminator convention.
let orderCodec = Json.compile orderSchema

String-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:

Verified
type LocaleTag = LocaleTag of string

let localizedText =
    Schema.mapWithKey LocaleTag (fun (LocaleTag value) -> value) Schema.text
Requires & types
Requires: open Reified (packages: Reified.Schema)
val localizedText: Schema<Map<LocaleTag,string>>

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:

Verified
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
Requires & types
Requires: open Reified (packages: Reified.Schema)

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:

Fragment
// Boundary parsing: complete diagnostics for untrusted input.
let parsed = Schema.parse customerSchema (Data.ofJsonDocument document)

For JSON lines without a model codec, Json.tryParseData returns Result<Data, string> instead of throwing on malformed lines. Null input still raises ArgumentNullException:

Verified
let line = Json.tryParseData "{\"name\":\"Ada\"}"
// Ok (Data.Object [ "name", Data.Text "Ada" ])
Requires & types
Requires: open Reified (packages: Reified.Schema)
val line: Result<Data,string>

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:

Verified
let bytes = Json.serializeBytes codec customer
let roundTripped = Json.deserializeBytes codec bytes
Requires & types
Requires: open Reified (packages: Reified.Schema)
val bytes: byte array
val roundTripped: Customer

Formatting The Output

serialize, serializeBytes, and serializeToStream emit compact JSON. For an indented string, logs, fixtures, config files a human reads, use serializeIndented:

Verified
let pretty = Json.serializeIndented codec customer
// {
//   "name": "Ada",
//   "age": 36,
//   "address": {
//     "street": "12 Analytical Way",
//     "city": "London"
//   }
// }
Requires & types
Requires: open Reified (packages: Reified.Schema)
val pretty: string

Every 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:

Verified
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
Requires & types
Requires: open Reified (packages: Reified.Schema)
val ascii: string
val forHtml: string

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 schema constraint metadata — rules attached with constrain/constraints, such as maxLength or between, or supply declarations. Those belong to boundary parsing and validation; a value that only ever passes through trusted systems does not pay for checks it already passed.
  • Refined and validated values are still constructed, and construction is where their own invariants live. Decoding a refined field re-runs the refinement's Constraint.check, and decoding a validate/tryConvert field runs that callback, because the codec cannot build the value otherwise. This is reconstruction, not a separate wire-constraint pass.
  • Checked constructors from constructResult still run, so intrinsic cross-field invariants hold on the trusted path; their errors surface as JsonCodecException.

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