Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultUnion Schemas
Three handwritten tools make the default wire model explicit:
Schema.enumwrites an all-fieldless union as a string;Schema.convertwrites a single-case domain wrapper as its underlying value;Schema.unionwrites every other union as an object with atypetag and named fields.
After choosing the shape by hand, [<DeriveUnion>] can generate the same schema from the F# declaration.
Write the default schema by hand
General unions: Schema.union
Consider a union with an empty case and a named payload field:
type Command =
| Stop
| SetVolume of amount: decimal
FsLiveDocsGeneratedPage22_76B7E1D94D35.CommandStopSetVolumeamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Select the payload case with a function that returns its fields and rejects every other case:
let trySetVolumeCase = function
| SetVolume amount -> Some amount
| _ -> NoneThen connect each F# case to Schema.union:
let commandSchema =
Schema.union [
UnionCase.empty "stop" Stop (function Stop -> true | _ -> false)
case "setVolume" {
tryExtract trySetVolumeCase
fieldAs "amount" id
construct SetVolume
}
]UnionCase.empty takes the JSON tag, the value to construct, and a function that recognizes the case. A case block
takes the tag, selects its payload with tryExtract, then declares named fields and the case constructor with the
record-schema vocabulary. The extractor returns None for every other case; getters inside the block operate only on
its selected payload.
The schema reads and writes:
{ "type": "stop" }
{ "type": "setVolume", "amount": 12.5 }
Every case uses the same type property. An empty case inside a mixed union remains { "type": "stop" }; it does
not become the string "stop".
Duplicate tags and a payload field named type are rejected when the schema is built: Schema.union throws
System.ArgumentException for either, because a tag must identify exactly one case and a payload field must not
collide with the discriminator. Case inspectors are checked when an existing value is validated with Schema.check:
an inspector that matches no case reports union.case, and more than one matching inspector reports
union.case.ambiguous. Parsing can only report an unknown tag, since tags are already unique.
String enums: Schema.enum
An all-fieldless union is a JSON string. Use Schema.enum with an explicit list of string tags and values;
no reflection or separate string field is needed:
type Status =
| Pending
| InProgress
| Complete
FsLiveDocsGeneratedPage22_76B7E1D94D35.StatusPendingInProgressCompletelet statusSchema =
Schema.enum
[ EnumCase.create "pending" Pending
EnumCase.create "inProgress" InProgress
EnumCase.create "complete" Complete ]
statusSchema: Schema<Status>Reified.SchemaConstruction, composition, parsing, and checking for universal schemas.
enum: EnumCase<'a> list -> Schema<'a>Describes a scalar enum.
Reified.EnumCaseFunctions for defining explicit payload-less enum schema cases.
create: string -> 'enum -> EnumCase<'enum>Describes one payload-less enum case from a tag and the union case value it represents. Thrown when is empty or whitespace. Thrown when is null.
PendingInProgressCompletelet codec = Json.compile statusSchema
let encoded = Json.serialize codec InProgress // "\"inProgress\""
let decoded = Json.deserialize codec encoded // InProgress
codec: JsonCodec<Status>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
statusSchema: Schema<Status>encoded: stringserialize: JsonCodec<'model> -> 'model -> stringSerializes a trusted model to a JSON string through a compiled codec. Thrown when is null.
InProgressdecoded: Statusdeserialize: 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.
"inProgress"
Domain wrappers: Schema.convert
A single-case union containing one value can use that value directly:
type CustomerId = CustomerId of string
FsLiveDocsGeneratedPage22_76B7E1D94D35.CustomerIdCustomerIdstringAn abbreviation for the CLI type . Basic Types
let customerIdSchema =
Schema.text
|> Schema.convert CustomerId (fun (CustomerId value) -> value)"customer-123"
Adding another case would change this JSON from a bare string to a tagged object. Use this form for domain wrappers, not for unions expected to gain alternatives.
Generate the same schemas
Schemagen applies the same three rules from the complete F# declaration:
- Every case is fieldless: generate
Schema.enum. - Exactly one case has exactly one field: generate a transparent
Schema.convertschema. - Otherwise: generate
Schema.unionwith atypetag and named fields.
Mark a general union with [<DeriveUnion>]:
[<DeriveUnion>]
type Command =
| Stop
| SetVolume of amount: decimal
| Move of x: int * y: intSchemagen generates JSON equivalent to the handwritten schemas:
{ "type": "stop" }
{ "type": "setVolume", "amount": 12.5 }
{ "type": "move", "x": 10, "y": 20 }
All-fieldless and single-case/single-field unions do not need [<DeriveUnion>] when they appear in a generated record.
Schemagen recognizes those two shapes automatically. It reads F# source during the build and uses no runtime
reflection.
Advice: name every payload field
Give every value a meaningful name:
// Recommended
type NamedCommand =
| SetVolume of amount: decimal
| Move of x: int * y: int
FsLiveDocsGeneratedPage22_76B7E1D94D35.NamedCommandSetVolumeamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Movex: intintAn abbreviation for the CLI type . Basic Types
y: intThe names become JSON properties:
{ "type": "setVolume", "amount": 12.5 }
{ "type": "move", "x": 10, "y": 20 }
Avoid unnamed fields:
// Avoid
type UnnamedCommand =
| SetVolume of decimal
| Move of int * int
FsLiveDocsGeneratedPage22_76B7E1D94D35.UnnamedCommandSetVolumedecimalAn abbreviation for the CLI type . Basic Types
MoveintAn abbreviation for the CLI type . Basic Types
FSharp.SystemTextJson accepts that declaration. With internal tagging and named fields, generated F# names can reach the wire:
{ "type": "setVolume", "item": 12.5 }
{ "type": "move", "item1": 10, "item2": 20 }
Those valid but meaningless keys can become a public contract and generate client members such as
item: BigDecimal. Renaming them later is a breaking wire change.
Reified schemagen does not invent placeholder names. It rejects an unnamed field on a general union with a build error. Name the field and rerun schemagen.
If those keys already belong to an established contract, keep them explicitly. See Advanced Union Handling.
Next
Use Advanced Union Handling to keep an existing serializer format, select internal, adjacent, or external tagging, choose named or positional payloads, and match FSharp.SystemTextJson options.