I'd like to share swift-json-schema, a SwiftPM build tool plugin that generates Codable Swift types from .schema.json files (JSON Schema Draft 2020-12) at compile time - with an optional fast path that bypasses Codable entirely via a zero-copy Span<UInt8> initializer.
The problems
When you have a JSON API contract, you typically maintain multiple implementations in sync. JSONSchema can solve this for validation, but decoding these types requires a Swift struct implementation. This is error-prone, and a source of subtle decode failures in production.
There's also the performance cost of Foundation.JSONDecoder. Inherent to the solution, Codable requires a lot of existential use, and a structured hierarchical representation of your DTO. This is inefficient compared to some other approaches, and does not work in Embedded Swift.
What the plugin does
Drop a .schema.json file into any target and apply the plugin:
{
"title": "User",
"description": "A registered user",
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"email": { "type": "string" },
"role": { "$ref": "#/$defs/Role" },
"address": { "$ref": "#/$defs/Address" }
},
"required": ["id", "email", "name", "role"],
"$defs": {
"Role": {
"enum": ["admin", "user", "guest"]
},
"Address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip-code": { "type": "string" }
},
"required": ["street", "city"]
}
}
}
At compile time, the plugin runs the generator and produces:
enum Role: String, Codable, Hashable {
case admin = "admin"
case guest = "guest"
case user = "user"
}
struct Address: Codable, Hashable {
let city: String
let street: String
var zipCode: String?
enum CodingKeys: String, CodingKey {
case city
case street
case zipCode = "zip-code"
}
}
/// A registered user
struct User: Codable, Hashable {
var address: Address?
let email: String
let id: Int
let name: String
let role: Role
}
A few things to note:
requiredfields becomelet; optional fields becomevar T?- JSON property names are converted to lowerCamelCase; a
CodingKeysenum is emitted automatically when they differ (e.g.zip-code→zipCode) - Swift reserved words are backtick-escaped
$defs/$refare resolved, and each definition becomes a named Swift typeoneOf/anyOfgenerate a Swiftenumwith associated values and a customCodableimplementation- Array constraints (
minItems,maxItems,uniqueItems) generate a validatinginit(from:)that throwsDecodingError.dataCorruptedon violation
No Foundation dependency
The generated code has zero Foundation imports. The generator itself is a pure Swift executable with no Foundation surface in its output. This matters for:
- Embedded Swift targets Foundation isn't available
- Binary size FoundationEssentials is 15–40 MB; not importing it at all is better
- Cross-platform reproducibility behavior doesn't vary between Foundation implementations
The generated structs conform to Codable (fallback for ergnomoics) and Hashable using only the Swift standard library.
While this blocks Embedded Swift use on the short term, we can conditionalize this conformance trivially if there is demand.
The fast path: zero-copy Span<UInt8> decoding
Enable the SwiftJSON package trait to generate three additional initializers on every struct, powered by IkigaJSON:
// Entry point: parse directly from a contiguous byte buffer - no copy
init(json span: Span<UInt8>) throws
// Core path: walk the JSON description index with typed accessors
init(view: borrowing JSONObjectView) throws
// Convenience: decode from an already-parsed JSONObject
init(json: JSONObject) throws
All three are guarded by #if canImport(IkigaJSON), so targets that don't enable the trait compile the same Codable-only output they always did.
The init(json span:) path constructs a JSONObjectView directly over the Span<UInt8> with no copy, then walks the JSON index using typed per-key accessors (string(forKey:), integer(forKey:), etc.) that read values directly from the original bytes. No intermediate Any boxing, no Decoder protocol overhead. A single allocation for all of the parser.
Whether you're using NIO.ByteBuffer, Foundation.Data or [UInt8], the Spans you provide can be parsed.
// `span` is a Span<UInt8> over your ByteBuffer's readable bytes
let user = try User(json: span)
Performance
Benchmarked on Apple M4-Max (p50 wall-clock, using the benchmark package):
| Payload | Foundation | IkigaJSON Codable | SwiftJSON init(json:) |
SwiftJSON ObjectView |
|---|---|---|---|---|
| Small (4 fields) | 4,335 ns · 15 allocs | 4,375 ns · 10 allocs | 2,583 ns · 5 allocs | 2,083 ns · 3 allocs |
| Medium (10 fields + nested) | 9,423 ns · 33 allocs | 10,000 ns · 26 allocs | 7,587 ns · 15 allocs | 6,335 ns · 9 allocs |
| Large (100 nested objects) | 408 µs · 1,752 allocs | 590 µs · 1,840 allocs | 603 µs · 1,328 allocs | 560 µs · 913 allocs |
The ObjectView path is 2× faster than Foundation on small payloads and 1.5× faster on medium ones. Across all sizes it allocates up to 5× fewer objects. On large payloads it matches Foundation's throughput while doing roughly half the heap work.
The IkigaJSON Codable decoder is actually slower than Foundation on large payloads - the Codable protocol overhead eats the parser's advantage. The generated typed-accessor path sidesteps this entirely.
This approach can reduce allocations down to one if your structs don't heap allocate (String, Array, Dictionary types).
Setting it up
Basic (Codable only):
.package(url: "https://github.com/wendylabsinc/swift-json-schema.git", from: "0.1.0"),
.target(
name: "MyTarget",
plugins: [.plugin(name: "JSONSchemaPlugin", package: "swift-json-schema")]
)
With the SwiftJSON fast path:
.package(
url: "https://github.com/wendylabsinc/swift-json-schema.git",
from: "0.1.0",
traits: ["SwiftJSON"]
),
.target(
name: "MyTarget",
dependencies: [
.product(name: "JSONSchemaSwiftJSON", package: "swift-json-schema"),
],
plugins: [.plugin(name: "JSONSchemaPlugin", package: "swift-json-schema")]
)
Status and roadmap
The plugin currently covers the subset of JSON Schema that maps cleanly to Swift's type system: type, properties, required, items, enum, oneOf/anyOf, $defs/$ref, description, and the array constraint keywords. String/number validation keywords (pattern, minimum, format, etc.) are parsed and silently ignored - they have no natural representation in generated Swift types.
Things I'm thinking about:
- String validation at decode time - emit a validating
init(from:)forpattern,minLength,maxLength constgenerate a single-case enum or a validated initializer- Remote
$reffetch and resolve external schema files during the build - Encoding support for the SwiftJSON path the fast path currently only covers decoding
Feedback and contributions welcome. The repo is at github.com/wendylabsinc/swift-json-schema and there's a working end-to-end example in Examples/UserDecoder/.