Guides
Unions
When a payload arrives as one of several shapes, what you want is an enum:
@Schema(keys: .snakeCase, discriminator: "type")enum Event { case click(Click) @Key("page_view") case pageView(PageView) case purchase(Purchase)}{"type": "click", "x": 12, "y": 40}The tag names the branch, the branch decodes the whole object, and the case name is the tag
value unless @Key says otherwise.
Prefer the tagged form
Section titled “Prefer the tagged form”Untagged looks more convenient. Tagged is better in every way that will matter to you, and it is worth saying plainly:
- The error is about one branch. The tag said
purchase, so what you get is apurchasefailure with its own caret. There is nothing to compose. - An unknown tag is one clear error, not “none of these four matched, here is why for each”.
- It encodes without an exception. The untagged form has one; see below.
- It is faster, and by a known amount: 1.19× the variant decoded directly, where the 9% is the tag scan itself. Scan the keys for the tag (values skipped structurally), rewind, decode the named branch. One pass over your document plus one decode.
Untagged, when the wire gives you no choice
Section titled “Untagged, when the wire gives you no choice”@Schema(discriminator: .untagged)enum Value { case number(NumberSpec) case text(TextSpec)}First match wins, in the order you declared them. Put the most specific first — a variant that accepts almost anything will shadow everything you wrote after it.
What a failure looks like
Section titled “What a failure looks like”When nothing matches you get a summary, plus the detail from the closest branch — whichever one got furthest before giving up:
error: value did not match any variant of Value (2 tried)error: value.maximum must be a number, found "ten"Producing that detail costs something you should know about: the measuring pass rolls every branch back, so by the time the winner is known its issues are gone, and the winner is run twice. The alternative — snapshotting every branch’s issues as it goes — would cost you an allocation per branch on every decode, including the ones that succeed.
Limits(verboseUnions: true) keeps every branch’s issues instead, for when you are
debugging which variant you meant.
What bounds the backtracking
Section titled “What bounds the backtracking”Limits.maxUnionAttempts (10,000 by default) caps total branch attempts across the whole
document, not per union. It is not refunded by a rewind — a nested untagged union in an
array is multiplicative, and a global budget is what makes that bounded.
Encoding
Section titled “Encoding”Both forms encode with encodes: true. The tagged form writes the tag first, which is not
cosmetic: any reader that has to scan past the payload to find the tag pays for it, yours and
Assay’s own included.
The untagged form carries the round-trip law’s fourth exception: if two of your variants’
types accept the same documents, re-decoding may hand you the other one. The macro refuses two cases
carrying the same payload token, but two distinct @Schema types that happen to accept
the same documents are indistinguishable to a macro. The tagged form has no such exception.
JSON only
Section titled “JSON only”Unions decode and encode from JSON, and that is a real limit rather than a queue position.
The tagged form needs to scan for the tag, then rewind and decode the branch over the
whole object — that is a byte reader’s operation. The RawValue projection every other
format goes through is a tree that has already been built, and the mechanism does not
transfer. Rather than silently omitting the body for YAML or XML, formats: including a
non-JSON format on a union is refused at expansion.
If you need a union from YAML: parse to YAML.Node, look at the tag yourself, and decode the
branch you picked. Three lines, and honest about what it is doing.
What a variant must be
Section titled “What a variant must be”A payload type is a @Schema type. Two rules the macro enforces:
- No two cases with the same payload type. For untagged that is undecidable at runtime; for tagged it is a copy-paste error. Either way it is a build error.
- A case with no payload is fine in the tagged form (the tag alone identifies it) and refused in the untagged one, where there would be nothing to match on.
Open enums are a different thing
Section titled “Open enums are a different thing”For a closed set of strings you need none of this — a plain RawRepresentable enum decodes
with no macro at all:
enum Status: String, JSONAssayable, CaseIterable { case active, archived }For a set that the server may add to:
@Schema enum Status { case active, archived @Unknown case other(String)}Anything unrecognised lands in .other with its string, rather than failing your whole
decode because somebody deployed a new status. See
Encoding for roundTrips:.