Header menu logo CodecMapper

Tagged Union Wire Shape Reference

This page is for lookup once you already know the authored tagged-union API:

It describes the exact wire shapes currently emitted by the built-in codecs.

Default field names

Schema.union uses these default wire field names:

Example schema:

open CodecMapper
open CodecMapper.Schema

type Status =
    | Pending
    | Failed of string

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

JSON shape

Payload-free cases encode as an object with only the discriminator:

{"case":"pending"}

Payload cases encode as an object with both fields:

{"case":"failed","value":"boom"}

XML shape

XML uses the schema-derived root element name, then nested discriminator and payload elements:

<status><case>pending</case></status>
<status><case>failed</case><value>boom</value></status>

YAML shape

YAML projects the same JSON structure:

case: pending
case: failed
value: boom

KeyValue shape

KeyValue flattens the same contract into dotted paths:

case=pending
case=failed
value=boom

Nested payloads keep extending the path:

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

Custom field names with unionNamed

Schema.unionNamed discriminatorName valueName changes the wire field names without changing the authored case names.

Example:

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

That changes the wire shape like this.

JSON:

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

XML:

<status><kind>failed</kind><details>boom</details></status>

YAML:

kind: failed
details: boom

KeyValue:

kind=failed
details=boom

Inline payload fields with inlineUnion

Schema.inlineUnion keeps the discriminator field, but merges payload members into the same object level instead of nesting them under a separate payload field.

Example:

type CreatedData = { Id: int; Name: string }
type Event =
    | Ping
    | Created of CreatedData

let createdDataSchema =
    define<CreatedData>
    |> construct (fun id name -> { Id = id; Name = name })
    |> field "id" _.Id
    |> field "name" _.Name
    |> build

let eventSchema =
    inlineUnion [
        tag "ping" Ping ((=) Ping)
        tagWith
            "created"
            (function Created payload -> Some payload | _ -> None)
            Created
            createdDataSchema
    ]

JSON:

{"case":"created","id":7,"name":"Ada"}

XML:

<event><case>created</case><id>7</id><name>Ada</name></event>

YAML:

case: created
id: 7
name: Ada

KeyValue:

case=created
id=7
name=Ada

Inline payload schemas must be object-shaped so the payload can contribute named members cleanly across all formats. Record schemas are the intended fit here.

Custom discriminator names with inlineUnionNamed

Schema.inlineUnionNamed discriminatorName changes only the discriminator field name for the inline shape.

JSON:

{"kind":"created","id":7,"name":"Ada"}

Message and envelope helpers

For message and event contracts, the helper names can read more directly than the generic tagged-union names:

Example envelope:

let eventSchema =
    envelope [
        message "ping" Ping ((=) Ping)
        messageWith
            "created"
            (function Created payload -> Some payload | _ -> None)
            Created
            createdDataSchema
    ]

JSON:

{"type":"created","data":{"id":7,"name":"Ada"}}

Inline envelope JSON:

{"type":"created","id":7,"name":"Ada"}

Recursive unions with delay

Schema.delay lets a union point back to itself:

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
        ])

That recursive authored contract currently compiles for:

JsonSchema.generate also exports it as a structural schema using local $defs / $ref.

Decode failure behavior

The codecs currently reject:

For KeyValue specifically, the payload-free case check matters because extra flattened keys would otherwise be easy to miss.

For readable, executable examples of malformed payloads and the expected error messages across JSON, XML, YAML, and KeyValue, see tests/CodecMapper.Tests/TaggedUnionErrorTests.fs.

type Status = | Pending | Failed of string
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val statusSchema: obj
union case Status.Pending: Status
union case Status.Failed: string -> Status
union case Option.Some: Value: 'T -> Option<'T>
union case Option.None: Option<'T>
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
Multiple items
module Event from Microsoft.FSharp.Control

--------------------
type Event<'T> = new: unit -> Event<'T> member Trigger: arg: 'T -> unit member Publish: IEvent<'T> with get

--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: objnull * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args> with get

--------------------
new: unit -> Event<'T>

--------------------
new: unit -> Event<'Delegate,'Args>
val id: x: 'T -> 'T

Type something to start searching.