Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonUnion 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
02-schema_45-union-schemas.md_page.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".
Reified rejects duplicate tags, a payload field named type, and inspectors that match either no case or more than
one case.
String enums: Schema.enum
An all-fieldless union is a JSON string:
type Status =
| Pending
| InProgress
| Complete
02-schema_45-union-schemas.md_page.StatusPendingInProgressCompletelet statusSchema =
Schema.enum
[ EnumCase.create "pending" Pending
EnumCase.create "inProgress" InProgress
EnumCase.create "complete" Complete ]"inProgress"
Domain wrappers: Schema.convert
A single-case union containing one value can use that value directly:
type CustomerId = CustomerId of string
02-schema_45-union-schemas.md_page.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
02-schema_45-union-schemas.md_page.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
02-schema_45-union-schemas.md_page.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.