Header menu logo CodecMapper

How To Model A Recursive Tagged Union

Use Schema.union when the JSON/XML/YAML/KeyValue wire shape should be an explicit tagged contract with a separate payload field. Use Schema.inlineUnion when payload members should sit next to the discriminator at the same level. Use Schema.delay when one of those tags needs to recurse back to the same schema.

This is the authored-schema path for recursive tree-like contracts. The wire contract stays explicit:

Model a recursive tree

open CodecMapper
open CodecMapper.Schema

type RecursiveNode =
    | Leaf of string
    | Branch of RecursiveNode

let rec nodeSchema : Schema<RecursiveNode> =
    delay (fun () ->
        union [
            tagWith
                "leaf"
                (function
                | Leaf value -> Some value
                | _ -> None)
                Leaf
                string
            tagWith
                "branch"
                (function
                | Branch value -> Some value
                | _ -> None)
                Branch
                nodeSchema
        ])

delay is the recursive anchor. Without it, the schema would try to construct itself immediately and never finish.

F# may warn about recursive object initialization (FS0040) when you author a recursive schema value with let rec. That warning is expected for this pattern. Schema.delay is the part that keeps the schema construction itself well-founded.

Compile once and reuse across formats

let jsonCodec = Json.compile nodeSchema
let xmlCodec = Xml.compile nodeSchema
let yamlCodec = Yaml.compile nodeSchema
let keyValueCodec = KeyValue.compile nodeSchema

let value = Branch(Branch(Leaf "ok"))

let json = Json.serialize jsonCodec value
let xml = Xml.serialize xmlCodec value
let yaml = Yaml.serialize yamlCodec value
let keyValues = KeyValue.serialize keyValueCodec value

For the value above, JSON uses the default wire field names:

{"case":"branch","value":{"case":"branch","value":{"case":"leaf","value":"ok"}}}

KeyValue flattens the same authored shape into dotted paths:

case=branch
value.case=branch
value.value.case=leaf
value.value.value=ok

Choose the case helpers deliberately

Use these helpers:

If you need the exact emitted JSON, XML, YAML, or KeyValue shapes, see Tagged Union Wire Shape Reference.

If you want concrete malformed payload examples and the exact failure messages the codecs are expected to produce, see tests/CodecMapper.Tests/TaggedUnionErrorTests.fs.

Example with a payload-free case and custom field names:

type Status =
    | Pending
    | Failed of string

let statusSchema =
    unionNamed "kind" "details" [
        tag "pending" Pending ((=) Pending)
        tagWith
            "failed"
            (function Failed message -> Some message | _ -> None)
            Failed
            string
    ]

That JSON shape becomes:

{"kind":"failed","details":"boom"}

Know the current boundaries

Recursive tagged unions currently compile for:

JsonSchema.generate can also export these authored tagged unions, including recursive shapes, using oneOf, const, and local $defs / $ref.

type RecursiveNode = | Leaf of string | Branch of RecursiveNode
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val nodeSchema: obj
union case RecursiveNode.Leaf: string -> RecursiveNode
union case Option.Some: Value: 'T -> Option<'T>
union case Option.None: Option<'T>
union case RecursiveNode.Branch: RecursiveNode -> RecursiveNode
val jsonCodec: obj
val xmlCodec: obj
val yamlCodec: obj
val keyValueCodec: obj
active recognizer KeyValue: System.Collections.Generic.KeyValuePair<'Key,'Value> -> 'Key * 'Value
val value: RecursiveNode
val json: obj
val xml: obj
val yaml: obj
val keyValues: obj

Type something to start searching.