Skip to content
Assay

Guides

Advanced

Things you will not need on day one, in roughly the order you are likely to reach for them.

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.

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

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.

For a type of yours that is a constrained string or number:

@Wraps(String.self, .min(3), .isLowercase)
struct Handle { let value: String }
@Schema
struct 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.

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.

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.

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.

try User.validate(user) // throws with everything
User.diagnose(user) // never throws

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

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

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 you
try Article.parse(json: data) // Data, from AssayFoundation

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

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.