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

Union Schemas

Three handwritten tools make the default wire model explicit:

  • Schema.enum writes an all-fieldless union as a string;
  • Schema.convert writes a single-case domain wrapper as its underlying value;
  • Schema.union writes every other union as an object with a type tag 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

Select the payload case with a function that returns its fields and rejects every other case:

let trySetVolumeCase = function
    | SetVolume amount -> Some amount
    | _ -> None

Then 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
let 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
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:

  1. Every case is fieldless: generate Schema.enum.
  2. Exactly one case has exactly one field: generate a transparent Schema.convert schema.
  3. Otherwise: generate Schema.union with a type tag and named fields.

Mark a general union with [<DeriveUnion>]:

[<DeriveUnion>]
type Command =
    | Stop
    | SetVolume of amount: decimal
    | Move of x: int * y: int

Schemagen 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

The 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

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.