Swift-json-schema: An Efficient Codable Alternative

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:

  • required fields become let; optional fields become var T?
  • JSON property names are converted to lowerCamelCase; a CodingKeys enum is emitted automatically when they differ (e.g. zip-codezipCode)
  • Swift reserved words are backtick-escaped
  • $defs/$ref are resolved, and each definition becomes a named Swift type
  • oneOf/anyOf generate a Swift enum with associated values and a custom Codable implementation
  • Array constraints (minItems, maxItems, uniqueItems) generate a validating init(from:) that throws DecodingError.dataCorrupted on 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:) for pattern, minLength, maxLength
  • const generate a single-case enum or a validated initializer
  • Remote $ref fetch 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/.

8 Likes