Guides
Advanced
Things you will not need on day one, in roughly the order you are likely to reach for them.
Transform
Section titled “Transform”Decode one type, keep another one:
@Transform({ (s: String) in URL(string: s) })@Inverse({ (u: URL) in u.absoluteString })var link: URL?The wire type comes from your closure’s parameter; the field’s type is whatever you declared.
Transform runs last in the pipeline — after decoding and after rules — so your rules see
the wire value. A nil result is an issue on that field.
You only need @Inverse with encodes: true, and leaving it out there is a build error
rather than a silently missing round trip.
Coerce
Section titled “Coerce”@Schema(coerceScalars: true) // whole type@Coerce var port: Int // one field"8080" decodes into an Int. The rules are written down and boring: "8080.5" is not an
integer, "true"/"yes"/"on"/"1" are true. You need it for XML, where every leaf is
text, and usually for CSV.
One or many
Section titled “One or many”For the APIs that send you a scalar when there is one item and an array when there are several:
@OneOrMany var tags: [String] // accepts "swift" and ["swift", "ios"]An honest asymmetry, stated rather than hidden: this attribute governs the JSON path,
where the choice is genuine. The RawValue path (YAML, XML, TOML) is already tolerant and
cannot be made strict — XML spells a sequence as repeated siblings, which is
indistinguishable from a scalar at that layer.
Wrapping a scalar
Section titled “Wrapping a scalar”For a type of yours that is a constrained string or number:
@Wraps(String.self, .min(3), .isLowercase)struct Handle { let value: String }
@Schemastruct Profile { var handle: Handle }A Handle field and @Validate(.min(3), .isLowercase) var handle: String hand you
identical issues — that equivalence is the feature. It costs about 1.98× the plain field
and rule it sugars, which is what you pay for a type the compiler keeps apart. The wrapped
type must be String, Int64, Double or Bool, because a macro sees a token.
Contexts
Section titled “Contexts”When your decoding needs something from outside the document — a tenant, a base URL, a feature flag:
@Schema(context: AppContext.self)struct Link { var path: String @Check static func allowed(_ l: Link, _ i: inout Issues<Link>, context: AppContext) { if !context.allowedPaths.contains(l.path) { i.add("is not permitted", at: \.path) } }}
let link = try Link.parse(json: data, context: ctx)A contextual type conforms to a different protocol, so parse(json:) without a context
does not exist for it. “You cannot forget to pass it” is the type system here, not
advice. The context-free expansion is byte-identical, so nothing costs anything when you do
not use one.
Schemas with no declaration
Section titled “Schemas with no declaration”When you only learn the shape at runtime — a form built from a database, a config schema shipped by a server:
let schema = Assayer<User>( fields: [ .field("id", .int), .field("email", .string, rules: [.email]), ], build: { User(id: $0["id"]!.int!, email: $0["email"]!.string!) })let user = try schema.parse(json: data)Same rules, same issues, same renderers you get from the macro path. It is also what @Wraps
is built on. Slower than the macro — there is a plan being interpreted rather than concrete
code — and that is the trade you are making.
Types the macro refuses
Section titled “Types the macro refuses”Each of these is a compile error naming the fix, rather than something that compiles and then behaves oddly on you.
| You wrote | Why not | Instead |
|---|---|---|
Set<T> |
a document carries an ordered array | [T], or @Transform({ (a: [T]) in Set(a) }) |
T? inside T?? |
a document has one kind of absence | T? |
[T?] |
an array holds values or nulls | [T] (a null element is an error), or [RawValue] |
| a tuple | no wire format has one | a nested @Schema struct, or an array |
| a function type | not decodable | — |
Any / AnyObject |
nothing to decode into | RawValue |
T! |
an absent key must be nil or an error | T? |
Character |
not a field type | String |
Data |
documents carry bytes as text | String + @Transform, or [UInt8]. This is about a Data field; parse(json: Data) is an input and works — see below |
URL |
— | @Validate(.url) var …: String, construct where you use it |
Decimal |
JSON numbers are doubles; a decimal should travel as a string | String + @Transform |
| a generic struct | a macro cannot specialise | a concrete type per instantiation |
UUID is a field type, with AssayFoundation imported — that product supplies the
conformance.
A closed set of strings or integers needs no macro at all:
enum Status: String, JSONAssayable, CaseIterable { case active, archived }Declare the conformance and the implementation arrives from a protocol extension, for any
RawRepresentable with a String or Int raw value. You write nothing in the body. Add RawDecodable too if the type decodes from YAML, XML, TOML or a plist, and
CaseIterable to make the error list the values it would have accepted.
(This example said Codable until 2026-09-11, which does not compile as a field —
Codable is the protocol this library replaces, not one it reads. Caught by turning the
guides into programs that have to run.) @Schema on an enum is for
unions and @Unknown open enums.
Validating without decoding
Section titled “Validating without decoding”try User.validate(user) // throws with everythingUser.diagnose(user) // never throwsThe schema’s rules against a value something else produced. This is the seam for a fast reader
of your own: decode at your speed in your module, then let Assay run the rules.
About 40 ns per value, or 47 ns per row over a batch — a tenth of what a full decode costs.
The batch form takes a sequence and puts the element index in the path, so an issue reads
[250003].email.
Describing the shape
Section titled “Describing the shape”@Schema(describes: true) struct Article { … }Article.jsonSchema(for: .input)A JSON Schema 2020-12 descriptor. .input describes what parse accepts, .output what your
value looks like after transforms — genuinely different once you have transforms, which is a
correction Zod shipped in v4.
Bytes, strings and Data
Section titled “Bytes, strings and Data”Assay reads UTF-8 bytes. [UInt8] is the real overload on every format and String is a
convenience that copies into one, so nothing here is string-first:
try Article.parse(json: bytes) // [UInt8]try Article.parse(json: text) // String — copied to UTF-8 for youtry Article.parse(json: data) // Data, from AssayFoundationHand it a Data and it decodes the buffer where it already sits. Array(data) — what you
would otherwise write — copies the whole document first, and that copy stays alive for the
whole parse. Skipping it saves one allocation per decode, whatever the size. On documents
from 0.2 to 8.3 MB it also saves 1–3.5% of the time. The memory is the point; the time is a
bonus.
One thing to know: after a clean decode from Data, d.source is empty. A Data’s
bytes are only valid for the length of the call, so Assay copies them only when an issue or
warning actually needs a caret. You handed the Data over, so you still have it — and
failures render exactly as they do from an array.
For a file, prefer parse(mmapped:): the kernel pages it in as the parse walks it, and errors
still render carets straight out of the mapping.
Two things that are not here
Section titled “Two things that are not here”Streaming. Decoding a document larger than memory, incrementally. It is out of scope,
and the reasoning is written down rather than left as a gap — the short version is that a
@Schema type is a fixed-size struct and a partially-decoded one is not a thing that
exists. parse(mmapped:) covers the “large file” case that people usually mean.
StandardSchema. The cross-library validation interface. The blocker is a repository
rather than a design: “Assay conforms to it” and “Assay does not depend on it” cannot both
hold in one package, so it needs a third adapter package.
- Attributes — all of them, one table.
- Design notes — why any of this is the way it is.