Skip to content
Assay

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 error
name: api
image: registry.internal/api:2.4.1
replicas: 0
health_check: https://api.internal/healthz
deploy.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 error
name = "api"
image = "registry.internal/api:2.4.1"
replicas = 0
health_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 error

Same rule, same message, same code (too_small), same parameters. Only the file extension and the column number moved.

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

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

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 String per scalar eagerly, and doing O(N·K) mapping lookup with an allocation per probe. Assay’s YAML parser measures 8.28× it.

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 struct

Two 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 type
bytes → YAML.Node → you walk it // YAML.decodeAll, when you want the tree

JSON is still the format people decode in a hot loop, and it is still the one with no projection in the middle at all.

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.

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.