Start
Coming from Codable
You know Codable. This page is the translation table, plus the four places where the answer is “you do not need that any more”.
The mapping, at a glance
Section titled “The mapping, at a glance”| Codable | Assay |
|---|---|
struct T: Codable |
@Schema struct T |
JSONDecoder().decode(T.self, from: data) |
T.parse(json: data) |
enum CodingKeys: String, CodingKey |
@Schema(keys: .snakeCase) or @Key("…") |
.keyDecodingStrategy = .convertFromSnakeCase |
@Schema(keys: .snakeCase) — at compile time |
init(from decoder:) for a default |
var x: Int = 3 |
decodeIfPresent |
var x: Int? |
.dateDecodingStrategy = .iso8601 |
the default; @DateFormat(…) to change it |
| A validation pass after decoding | @Validate, @Check — in the same pass |
try/catch and read DecodingError |
T.diagnose(…) and read .issues |
Encodable |
@Schema(encodes: true) |
Four things you stop doing
Section titled “Four things you stop doing”1. Writing CodingKeys
Section titled “1. Writing CodingKeys”The whole enum, for a rename or a snake_case API, is one attribute:
// Beforestruct User: Codable { let userID: Int let displayName: String enum CodingKeys: String, CodingKey { case userID = "id" case displayName = "display_name" }}
// After@Schema(keys: .snakeCase)struct User { @Key("id") var userID: Int var displayName: String}2. Writing init(from decoder:) for a default
Section titled “2. Writing init(from decoder:) for a default”The most common reason anyone hand-writes a decoder is “this field should default to something”. In Assay that is the declaration:
// Before: 12 lines of container juggling.// After:var retries: Int = 3And the four other presence cases have spellings too — see Presence.
3. A separate validation pass
Section titled “3. A separate validation pass”The usual shape is: decode, then walk the value checking it, then build your own error type. That is two traversals and two error vocabularies.
// Beforelet user = try JSONDecoder().decode(User.self, from: data)guard user.email.contains("@") else { throw AppError.badEmail }guard user.age >= 13 else { throw AppError.tooYoung }
// After@Schemastruct User { @Validate(.email) var email: String @Validate(.min(13)) var age: Int}Same pass, same error type, same renderer. And the rule is checked against the field’s type
when you build, so .email on an Int never reaches production.
4. Catching on the first error
Section titled “4. Catching on the first error”JSONDecoder throws on the first problem it finds. For a network payload that is fine. For
a config file, a form submission or a CSV import it is a loop — run, fix, run, fix.
let d = Settings.diagnose(json: data)// d.issues has ALL of them, each with a path and a source span.Four things that behave differently
Section titled “Four things that behave differently”var x = 3 is a compile error
Section titled “var x = 3 is a compile error”Codable lets you write var count = 3 and infers Int. Assay refuses it, on purpose:
that spelling reads as “default 3” to one person and “always 3” to another. Write the type:
var count: Int = 3 // ✓ decoded, defaults to 3let limit: Int = 100 // ✗ compile error — a `let` with a value is not a field@Ignore is how you say “this property is not a field”.
Unknown keys are ignored, unless you say otherwise
Section titled “Unknown keys are ignored, unless you say otherwise”Same as Codable’s default. But you have three other choices, which Codable does not offer at all:
@Schema(unknownKeys: .warn) // report them, keep decoding — with did-you-mean@Schema(unknownKeys: .reject) // an unknown key is an error@Schema(unknownKeys: .collect) // put them in an @Extras dictionaryErrors are values, not strings
Section titled “Errors are values, not strings”An Issue is a code plus parameters plus a path plus a source span. .message is derived
when you ask for it. That means you can branch on issue.code, translate it, or map it
onto your form fields — none of which is practical with DecodingError’s associated
Context.
Some types are not field types
Section titled “Some types are not field types”Set, Data, URL, Decimal, tuples, Any and a few others are refused as field
types, at expansion, with a message naming the alternative — usually “decode as String and convert with
@Transform”. Codable would accept several of these and then behave in a way you did not
intend. Advanced lists them all with the reason
and the fix. Note the difference between a Data field, which is refused, and
parse(json: data), which takes a Data as the document and is the fastest way to hand one
over.
Migrating a type, in order
Section titled “Migrating a type, in order”- Replace
: Codablewith@Schema. If the type used.convertFromSnakeCase, addkeys: .snakeCase. - Delete
CodingKeys, replacing any genuine renames with@Key("…"). - Delete
init(from:)if it existed only for defaults; write the defaults on the properties. - Replace
JSONDecoder().decode(…)withT.parse(json:). - Move any post-decode validation into
@Validate/@Check. - Build. What the macro refuses, it refuses with a message that says what to do instead.
You can do this one type at a time. An Assay type and a Codable type coexist fine — they are unrelated protocols.
Both at once
Section titled “Both at once”Nothing stops a type being both:
@Schemastruct User: Codable { var id: Int var name: String}Useful while migrating, or when something else in your stack insists on Encodable.
@Schema(encodes: true) gives you Assay’s own writer if you would rather not keep
Codable around for it.
What the swap actually buys
Section titled “What the swap actually buys”Errors are the reason to do it: all of them at once, each pointing at the byte, on five formats, with the same rules everywhere.
Speed comes along for the ride. On this corpus the struct path measures about 9× a
JSONDecoder, encoding about 8.28× a JSONEncoder, and YAML about 17× Yams
through the same declaration — mostly because there is no Codable container boundary left
to pay for. The cost is build time: roughly 84 ms per type at ten fields, which is about 4×
what Codable costs to synthesise, and gated in CI so it stays there.
Performance has every number, what it was measured against, and the arms where Assay loses to hand-written C.
- Cheatsheet — the whole surface on one page.
- Presence — the five states, properly.
- Errors — what to do with what you get back.