Assay
Formats
Most decoders read one format. Assay reads five, and the point is not the count — it is that your struct, your rules, your keys and your errors are identical across all of them. You learn one thing.
@Schema(keys: .snakeCase, formats: .all)struct Deployment { @Validate(.min(1), .max(63)) var name: String var image: String @Validate(.min(1)) var replicas: Int var region: String = "eu-west-1" @Validate(.url) var healthCheck: String?}One replicas: 0 mistake, four documents, four carets:
{ "name": "api", "image": "registry.internal/api:2.4.1", "replicas": 0, "health_check": "https://api.internal/healthz"}deploy.json:4:15: error: replicas must be at least 1 2 │ "name": "api", 3 │ "image": "registry.internal/api:2.4.1", 4 │ "replicas": 0, │ ^ 5 │ "health_check": "https://api.internal/healthz"
1 errorname: apiimage: registry.internal/api:2.4.1replicas: 0health_check: https://api.internal/healthzdeploy.yaml:3:11: error: replicas must be at least 1 1 │ name: api 2 │ image: registry.internal/api:2.4.1 3 │ replicas: 0 │ ^ 4 │ health_check: https://api.internal/healthz
1 errorname = "api"image = "registry.internal/api:2.4.1"replicas = 0health_check = "https://api.internal/healthz"deploy.toml:3:12: error: replicas must be at least 1 1 │ name = "api" 2 │ image = "registry.internal/api:2.4.1" 3 │ replicas = 0 │ ^ 4 │ health_check = "https://api.internal/healthz"
1 error<deployment> <name>api</name> <image>registry.internal/api:2.4.1</image> <replicas>0</replicas> <health_check>https://api.internal/healthz</health_check></deployment>deploy.xml:4:13: error: replicas must be at least 1 2 │ <name>api</name> 3 │ <image>registry.internal/api:2.4.1</image> 4 │ <replicas>0</replicas> │ ^ 5 │ <health_check>https://api.internal/healthz</health_check>
1 errorSame rule, same message, same code (too_small), same parameters. Only the file extension
and the column number moved.
The five
Section titled “The five”| Format | Product | Page | Notes |
|---|---|---|---|
| JSON | Assay |
JSON | RFC 8259. Decodes straight from bytes — the fast path |
| YAML 1.2 | AssayYAML |
YAML | Anchors, aliases, block scalars, multi-document |
| XML 1.0 | AssayXML |
XML | Namespaces, CDATA, attribute/element/text placement |
| TOML 1.0.0 | AssayTOML |
TOML | every case of the official toml-test suite |
| Property lists | AssayPlist |
Property lists | Binary and XML behind one entry point |
Plus one thing that is not a file format but decodes the same way:
- HTTP bodies — pick the parser from a
Content-Type, safely.
Opt in per type
Section titled “Opt in per type”@Schema // JSON only — the default@Schema(formats: [.json, .yaml]) // two@Schema(formats: .all) // JSON, YAML, XML, TOML@Schema(formats: [.toml]) // TOML only; no JSON body emittedGenerated code is not free. A shared YAML/XML/TOML body adds about 34 ms per type to your build — roughly 41% of the expansion. A type that only ever sees JSON should not pay for a parser it never calls, so you ask for what you read.
Calling parse(yaml:) on a type that did not list .yaml is a compile error, not a
runtime one: the conformance that entry point needs simply is not there.
Two things the list does not say out loud. Listing any of YAML, XML or TOML turns JSON
off unless you also list it, because a type that only ever reads TOML should not carry a
JSON body. And there is no .plist — property lists decode through the same projection
those three use, so any of them brings parse(plist:) along.
Everything hand-written
Section titled “Everything hand-written”No libyaml, no libxml2, nothing to vendor. Every parser here is pure Swift, and that is not purism:
- It runs everywhere. macOS, Linux and Windows, plus static-musl and WebAssembly
cross-compiles, with no
__declspec(dllimport)trap and no system library to be missing. - The carets work. A C parser hands back a tree, not byte offsets you can trust to survive into an error message.
- XXE is refused by construction rather than by configuration — there is no code path that could fetch an external entity, so there is no flag to forget.
- It is fast. A survey of the Swift YAML options found one allocating a class per node,
a
Stringper scalar eagerly, and doing O(N·K) mapping lookup with an allocation per probe. Assay’s YAML parser measures 8.28× it.
Two decode paths
Section titled “Two decode paths”JSON decodes directly from bytes into your struct. That is the fast path, and where the 8.75× mean over Foundation comes from.
Every other format goes through RawValue, the format-neutral projection:
bytes → RawValue → your structTwo consequences, and one of them is not what it used to be. Your rules, keys, presence
states and checks are identical across formats, because they all run on the far side of the
projection — that part is the whole point. And the tree path is slower, but it no longer
builds a node tree to get there: until September 2026 each parser built its own
YAML.Node or XML.Element tree, projected it into RawValue, and dropped the tree.
Each parser is now generic over what it builds, so the struct door builds RawValue
directly and the tree door still builds a tree when you ask for one:
bytes → RawValue → your struct // parse(yaml:) into a @Schema typebytes → YAML.Node → you walk it // YAML.decodeAll, when you want the treeJSON is still the format people decode in a hot loop, and it is still the one with no projection in the middle at all.
Where the formats genuinely differ
Section titled “Where the formats genuinely differ”Everything above is the same. These are the four places the format itself forces a difference, each covered on its own page.
XML has no types. Every leaf is text, so a struct decoding from XML needs
coerceScalars: true. It also has attributes, which no other format does — hence
@XML(.attribute).
YAML will not guess for you. A plain scalar keeps its text until something asks a typed
question, so NO is the string "NO" for a String field. That is the Norway problem
solved by not having an opinion, and it means enabled: no is an error rather than
silently false.
TOML is typed on the wire. 1 is an integer and "1" is a string, by the grammar. It
also has four date-time kinds, which arrive as RFC 3339 strings.
Property lists are two formats. The XML flavour is a document; the binary one is a random-access object graph with amplification attacks that no depth limit catches.
When you do not know the shape
Section titled “When you do not know the shape”Every format has a value model you can walk by hand — JSON.Value, YAML.Node,
XML.Document, TOML.Node, and RawValue as the portable intersection. Each page ends
with its own.
They are deliberately not unified behind one type: a YAML scalar’s resolution and an XML element’s namespace are not the same kind of thing, and pretending otherwise loses information.
Pick a format, or read Errors first — it is the same on all of them.