Assay
Enums
A vocabulary comes in three flavours and they want different declarations: one you control, one somebody upstream will add to without telling you, and one that is really a scalar wearing a struct.
A closed set
Section titled “A closed set”enum Colour: String, JSONAssayable, RawDecodable, CaseIterable { case red, green, blue }enum Priority: Int, JSONAssayable, RawDecodable { case low = 1, high = 2 }
@Schema(keys: .snakeCase, formats: .all)struct Label: Equatable { var colour: Colour; var priority: Priority }YAML here, because a closed vocabulary is usually something a person types by hand.
RawDecodable is the conformance that makes these work outside JSON — drop it and
parse(yaml:) will not compile for a type with an enum field.
colour: greenpriority: 2Label(colour: Colour.green, priority: Priority.high)You put no macro on the enum. Declare the conformance and the implementation arrives from a
protocol extension, for any RawRepresentable with a String or Int raw value. Add
RawDecodable too if your type decodes from YAML, XML, TOML or a plist.
CaseIterable is optional and pays for itself:
colour: chartreusepriority: 9e.yaml: error: colour "chartreuse" is not a recognised value; must be one of "red", "green", "blue"
e.yaml: error: priority "9" is not a recognised value
2 errorsThe first field tells you what it would have accepted. The second, without CaseIterable,
cannot.
A set that will grow
Section titled “A set that will grow”@Schema(formats: .all) enum Plan: Equatable { case free, pro @Unknown case other(String)}
@Schema(keys: .snakeCase, formats: .all) struct Membership: Equatable { var plan: Plan }plan: enterpriseMembership(plan: Plan.other("enterprise"))The raw value is kept, so you can branch on the cases you know, treat the rest as a default, and log what is actually arriving. Without this, one new value upstream is a decode failure for every record that has it.
Encoding refuses to write an unrecognised variant unless you opt in with
@Unknown(roundTrips: true). Writing back a value you never understood is a decision, not a
default.
Sometimes one, sometimes many
Section titled “Sometimes one, sometimes many”@Schema(keys: .snakeCase, formats: .all)struct Post: Equatable { @OneOrMany var tags: [String] }{"tags": "swift"}(Post(tags: ["swift"]), Post(tags: ["swift", "json"]))Both documents decode. The attribute lands on the JSON path, where the choice is genuine. On the other formats the tolerance is already there and cannot be taken away: XML spells a sequence as repeated sibling elements, which is indistinguishable from a single scalar at that layer.
A struct that is really a scalar
Section titled “A struct that is really a scalar”@Wraps(String.self, .email) struct EmailAddress {}@Wraps(Int64.self, .range(1...100)) struct Percent {}
@Schema(keys: .snakeCase, formats: .all)struct Contact: Equatable { var contact: EmailAddress; var complete: Percent }contact: not-an-emailcomplete: 150w.yaml: error: contact must be a valid email address
w.yaml: error: complete must be between 1 and 100
2 errorsA wrapper gives you a type the compiler can keep apart from every other String, with the
validation attached to the type rather than repeated at each use site.
The issues are byte-identical to @Validate(.email) on a plain String — same code,
same path, same parameters. That equivalence is the feature: a wrapper is not a second
validation mechanism wearing the first one’s vocabulary.
The wrapped type is String, Int64, Double or Bool, because a macro sees a token and
not a type. It costs you about 1.98× the plain field and rule it is sugar for, which is the
price of the distinct type.