Guides
Checks
A rule is a value: .min(3), .email. When what you need is a function of your own, that
is a check.
One field
Section titled “One field”@Schemastruct Signup { var email: String
@Check(\Signup.email) static func companyAddress(_ e: String) -> String? { e.hasSuffix("@acme.com") ? nil : "must be a company address" }}Return nil for fine, or the message for not fine. Your key path says which field the issue
lands on, so it renders with that field’s caret:
error: email must be a company addressYour parameter type has to be the field’s type. If it is not, the macro tells you when you build, naming both.
The key path needs its root. \Signup.email, not \.email. An attached macro’s
argument has no contextual type to infer Root from — this is a Swift limitation, not a
style choice, and the short form has never compiled.
Across fields
Section titled “Across fields”@Schemastruct DateRange { var start: Int var end: Int
@Check static func ordered(_ r: DateRange, _ issues: inout Issues<DateRange>) { if r.end < r.start { issues.add("must be on or after start", at: \.end) } }}No key path on the attribute. Your function takes the whole value and an issue collector, and
reports wherever you like. Inside it \.end is enough — there is a contextual type here.
The signature has to be exactly (Self, inout Issues<Self>). Anything else gets you a
diagnostic naming the shape it wanted.
Machine-readable codes
Section titled “Machine-readable codes”issues.add("…") uses the message as the code, which is right for a one-off. When something
downstream needs to branch on it:
issues.add(code: "password_is_email", "must not be your email address", at: \.password)Now issue.code == .custom("password_is_email"), and the message is still there for humans.
Translation works the same way: match on the code, render your own words.
Asking something slow
Section titled “Asking something slow”@Schemastruct Signup { var username: String
@AsyncCheck static func available(_ s: Signup, _ issues: inout Issues<Signup>) async { if await db.userExists(s.username) { issues.add("is already taken", at: \.username) } }}One @AsyncCheck anywhere in the type makes parse and diagnose async for that type,
decided by counting attributes at compile time. A type without one stays synchronous, so
you never await a schema that has nothing to await.
Three things happen in a fixed order, and the order is the part worth knowing:
- Everything synchronous runs first and collects all of its issues.
- Async checks run only if the sync pass was clean. Spending a database round trip to ask about a username you already know is 200 characters long is waste.
- When they do run, they all run concurrently.
let user = try await Signup.parse(json: data)Where checks sit in the pipeline
Section titled “Where checks sit in the pipeline”preprocess → coerce → decode → field rules → cross-field checks → transform → async checksCross-field checks see the constructed value, so every field has already decoded and passed
its own rules. That is why your check can assume start and end are both Ints instead of
re-deriving it.
Rules about checks
Section titled “Rules about checks”A check must be declared inside the type. In an extension it is permanently invisible to the macro — a macro only sees the declaration it is attached to — so that is a compile error with a message rather than a rule that silently never runs.
A failing check means no value is produced. Like any other issue: diagnose hands you
nil for the value plus the issues, parse throws.
Checks run on every path. JSON, YAML, XML, TOML and validate(_:) on a value something
else produced.
When to reach for what
Section titled “When to reach for what”| You want | Use |
|---|---|
| A bound, a format, a set | @Validate |
| A function over one field | @Check(\T.field) |
| A relationship between fields | @Check |
Anything needing await |
@AsyncCheck |
| To change the value, not judge it | @Transform |