Skip to content
Assay

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.

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 a purchase failure 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.

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.

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.

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.

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.

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.

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

  • Encoding — the exception list in full.
  • Advanced — contexts, wrappers, runtime schemas.