Skip to content
Assay

Assay

Unknown shapes

Two different problems get solved here. Sometimes you cannot declare the shape because you do not know it. Sometimes you know it perfectly well and have nowhere to hang a macro.

let v = try JSON.Value.parse(bytes)
{"kind": "batch", "items": [{"id": 1}, {"id": 2}], "meta": null}
v["kind"]?.string -> batch
v["items"]?[1]?["id"]?.int -> 2
v["items"]?.array?.count -> 2
v["nope"] -> nil

Subscripts are optional-chaining all the way down, so a wrong guess at any level hands you nil rather than a trap. Object members keep their order and their duplicates.

Every format has one, and they are deliberately not unified behind a single type: a YAML scalar’s resolution and an XML element’s namespace are not the same kind of thing.

keeps
JSON.Value order, duplicate keys, int/double distinct
YAML.Node quoting style, tags, anchors, unresolved scalars
XML.Document namespaces, attributes, comments, mixed-content order
TOML.Node which of the four date-time kinds
RawValue the portable intersection every non-JSON format decodes through

One honest note before you reach for it: this is not the fast path. Building a tree has no Codable boundary to delete, so the argument that makes @Schema fast does not apply. Where a declared struct comes out of JSON at 8.75× JSONDecoder across the corpus, the value model manages 3.31× — worth having, and worth knowing you are leaving something behind. Performance has the rest.

A schema with no declaration to attach a macro to

Section titled “A schema with no declaration to attach a macro to”

Sometimes the type is not yours to annotate, or it is a constrained scalar rather than a struct with fields. You get the same decoding either way.

struct Ticket: Equatable, Sendable { var raw: String }
extension Ticket: AssayerBacked {
nonisolated static let assaySchema =
Assayer.string.validate(.prefix("TCK-"), .length(9)).map(Ticket.init(raw:))
}
@Schema(keys: .snakeCase) struct Booking: Equatable { var ticket: Ticket }
{"ticket": "TCK-00042"}
Booking(ticket: Ticket(raw: "TCK-00042"))

And when it does not match:

{"ticket": "XX-1"}
t.json: error: ticket must start with "TCK-"
t.json: error: ticket must be exactly 9 characters
2 errors

Same codes, same paths, same renderer as any other field. A conforming type is already a nested schema type as far as the macro is concerned — it needed no change at all to support this, which is the design’s whole claim.

map is failable on purpose, so a conversion that refuses a value the rules accepted is reported rather than swallowed.

For the common case of a wrapper with rules, @Wraps is sugar over exactly this.

@Schema(context: AppContext.self)
struct Document { … }
try Document.parse(json: bytes, context: ctx)

A contextual type conforms to ContextualJSONAssayable and not JSONAssayable, so parse(json:) without a context does not exist for it. “You cannot forget to pass it” is the type system rather than advice.

  • Advanced — the reasoning behind all three.