Assay
Unions
Sooner or later a field arrives as one of several shapes. If the document tells you which, you are in easy territory. If it does not, you are making a decision about precedence, and this page is mostly about making it deliberately.
Tagged, which is the one to reach for
Section titled “Tagged, which is the one to reach for”@Schema(keys: .snakeCase) struct Click: Equatable { var x: Int; var y: Int }@Schema(keys: .snakeCase) struct View: Equatable { var path: String }
@Schema(keys: .snakeCase, discriminator: "type")enum Event: Equatable { case click(Click) @Key("page_view") case pageView(View)}{"type": "page_view", "path": "/pricing"}pageView(View(path: "/pricing"))A key in the object says which variant it is. @Key on a case overrides the tag
spelling, exactly as it does for a field.
An unrecognised tag says so, and lists what it knows:
{"type": "scroll", "path": "/pricing"}ev.json: error: type must be one of click, page_view, found scroll
1 errorThe tag costs almost nothing to read: a tagged union measures 1.19× the variant decoded directly, and that 9% is the tag scan.
Put the tag first when you write these documents, and that number stays true. Assay scans keys for the tag and skips values structurally, so a tag at the end means pre-scanning the whole object — including every document Assay itself wrote, if the encoder did not lead with it. It does.
Untagged, when the wire gives you nothing
Section titled “Untagged, when the wire gives you nothing”@Schema(keys: .snakeCase) struct Number: Equatable { var value: Double }@Schema(keys: .snakeCase) struct Text: Equatable { var text: String }
@Schema(discriminator: .untagged)enum Scalar: Equatable { case number(Number) case text(Text)}{"text": "hello"}(Scalar.number(Number(value: 1.5)), Scalar.text(Text(text: "hello")))Each branch is tried in order and the first that decodes wins. The order you write the cases in is therefore semantics, not style — reorder them and you change what the type accepts.
Two things to know before you reach for it. A failure reports one summary plus the
closest branch’s detail rather than four walls of text, and producing that detail means
running the winner twice. And maxUnionAttempts is a global budget across the whole decode
rather than per union, because the blow-up being guarded against is nested unions,
not one union with many branches — [[U]] with three branches costs three attempts per
element and 3ⁿ for n levels, which maxDepth cannot see.
Two variants that accept the same documents is a real hazard, and the macro cannot catch it for you: it refuses only the same payload token. That is the fourth exception to the round-trip law, and it is yours to avoid.
JSON only
Section titled “JSON only”Unions decode from JSON and nothing else, and you are told so at expansion rather than left
to discover it. A union has no RawValue path to decode through: the tag scan and the
rewind are both operations on bytes.
- Encoding — writing a union back out.
- Unions, explained — the four hard questions and their answers.