Assay
Checks and transforms
A rule covers the shape of a value. When what you need is your logic — a reserved-name list, two fields that have to agree, a type the wire does not have — this is where you put it.
One field, your own logic
Section titled “One field, your own logic”@Schema(keys: .snakeCase)struct Handle: Equatable { var username: String
@Check(\Handle.username) static func notReserved(_ u: String) -> String? { ["admin", "root"].contains(u) ? "is reserved" : nil }}{"username": "admin"}u.json:1:14: error: username is reserved 1 │ {"username": "admin"} │ ^^^^^^^
1 errorReturn nil to pass, a message to fail. You have to write the key path root — \.username
has never compiled, because an attached macro’s argument has no type to infer it from.
There is no .custom { } rule for the same reason: a closure in an attribute has no type
context.
Across fields
Section titled “Across fields”@Schema(keys: .snakeCase)struct Window: Equatable { var start: Int var end: Int
@Check static func ordered(_ r: Window, _ issues: inout Issues<Window>) { if r.end < r.start { issues.add("must be on or after start", at: \.end) } }}{"start": 10, "end": 3}r.json: error: end must be on or after start
1 errorYou get the whole value, after every field has decoded and passed its own rules. at: puts
the issue on the field a person should look at, which is what makes it land next to the right
input when you render a form.
issues.add(code:_:at:) takes a code of your own when you want to branch on it later.
Change the type after decoding
Section titled “Change the type after decoding”@Schema(keys: .snakeCase, encodes: true)struct Timeouts: Equatable { @Transform({ (a: [String]) in Set(a) }) @Inverse({ (s: Set<String>) in s.sorted() }) var tags: Set<String>
@Transform({ (ms: Int) in Double(ms) / 1000.0 }) @Inverse({ (s: Double) in Int(s * 1000) }) var timeoutSeconds: Double}{"tags": ["b", "a", "b"], "timeout_seconds": 1500}Timeouts(tags: Set(["b", "a"]), timeoutSeconds: 1.5)The closure’s parameter type is what gets decoded; the field keeps its own type. That
is how Set becomes a legal field even though the macro refuses it directly, and how you
take milliseconds off the wire and store seconds without keeping a second property.
Transforms run last, after rules and checks.
Transforms and encoding
Section titled “Transforms and encoding”@Inverse is required once the type encodes, and the build fails naming the signature
if it is missing. Otherwise a round trip would quietly write something different:
{"tags": ["b", "a", "b"], "timeout_seconds": 1500}{"tags":["a","b"],"timeout_seconds":1500}Out again as a sorted array and milliseconds. This is the round-trip law doing its job.
Slow checks
Section titled “Slow checks”@AsyncCheckstatic func usernameIsFree(_ s: Handle, _ issues: inout Issues<Handle>) async { … }One of these makes parse async for that type, by a compile-time count. The sync pass
runs first and collects everything; the async checks run only if it was clean, and then
concurrently. There is no point asking a database whether a username is taken when the
username is already four characters too short.
- Dates — the other thing that needs more than a rule.
- Checks, explained — the pipeline order, in full.