Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonUnion Schemas
Start with the three handwritten tools. They 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 payload case:
type SetVolumeFields = { Amount: decimal }
type Command =
| Stop
| SetVolume of SetVolumeFields
08-schema_45-union-schemas.md_page.SetVolumeFieldsAmount: decimaldecimalAn abbreviation for the CLI type . Basic Types
08-schema_45-union-schemas.md_page.CommandStopSetVolumeFirst build the payload's record schema:
let setVolumeFields =
schema<SetVolumeFields> {
field _.Amount
construct (fun amount -> { Amount = amount })
}Then connect each F# case to Schema.union:
let commandSchema =
Schema.union
[ UnionCase.empty "stop" Stop _.IsStop
UnionCase.fields
"setVolume"
SetVolume
(function SetVolume fields -> Some fields | _ -> None)
setVolumeFields ]UnionCase.empty takes the JSON tag, the value to construct, and a function that recognizes the case.
UnionCase.fields takes the tag, the case constructor, a function that extracts its fields, and the fields' schema.
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
08-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
08-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
08-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
08-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.