Skip to content
Assay

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.

@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 error

The 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.

@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.

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.