Skip to main content

JSON to Swift Codable: Generate Structs, Optionals, and CodingKeys

Turn any JSON payload into Swift Codable structs. How type inference picks Int vs Double, when fields become Optional, and how CodingKeys maps snake_case keys.

Published By Li Lei
#swift #json #codable #ios #api

JSON to Swift Codable: Generate Structs, Optionals, and CodingKeys

The slowest part of wiring up an iOS networking layer is rarely the request. It's transcribing a JSON response into Swift types by hand: spelling out 30 properties, second-guessing whether a field is Int or Double, and remembering to map every created_at back to a createdAt property. Get one key wrong and JSONDecoder throws keyNotFound at runtime instead of at the compiler. This guide walks through how to generate a Codable model from a real payload, and what each decision the generator makes actually means.

Each Key Becomes a Typed Property

The core rule is mechanical and predictable: every key in your JSON object becomes one stored property in a Codable struct, and the property's type is inferred from the value the generator sees. A string value yields String, a boolean yields Bool, an array of strings yields [String], and a nested object lifts into its own named type. Numbers are split rather than flattened, which is the part most hand-written models get wrong.

JSON has a single number type, so the generator looks at the actual value. An integer-valued sample like 42 infers Int. A value with a fractional part like 19.99 infers Double. And a field that arrives whole in one sample but decimal in another widens to Double, the safe superset, so you never get a decode failure when the server sends 5.0 where it once sent 5. Swift's Int is 64-bit on every current platform, so large IDs and millisecond timestamps fit without reaching for Int64.

Nested objects nest. If a user object contains an address object, the generator emits a UserAddress type and references it from the parent as let address: UserAddress. Arrays of objects are smarter still: [{"a":1},{"a":1,"b":2}] folds into a single struct carrying both fields rather than two near-duplicate types, and because b is missing from the first element, it becomes Optional.

CodingKeys: Mapping snake_case to Swift

Most APIs send snake_case, but idiomatic Swift properties are camelCase. The generator camelCases each key (site_admin to siteAdmin, first-name to firstName) and then bridges the gap with a CodingKeys: String, CodingKey enum. When a property name diverges from its wire key, the enum carries an explicit case like case userId = "user_id", so JSONDecoder round-trips without any global keyDecodingStrategy.

The nicety is that the enum is only written when it's needed. If every key already matches its property (id, name, email), the enum is omitted and Swift synthesizes CodingKeys for you, keeping the type short. If you'd rather document the wire contract explicitly — useful in a shared API package where reviewers want every key visible — there's an "Always emit CodingKeys" toggle that prints bare cases like case id even when no remap is required. Reserved-word keys are handled too: a key named default becomes ` let default: Bool ` with backtick escaping so the output always compiles.

A Worked Example

Here's a small order payload from a REST API:

{
  "order_id": 1042,
  "total_amount": 29.99,
  "is_paid": true,
  "customer": {
    "full_name": "Ada Lovelace",
    "email": "ada@example.com"
  },
  "coupon_code": null
}

Paste that in, set the root type to Order, and you get:

struct Order: Codable {
    let orderId: Int
    let totalAmount: Double
    let isPaid: Bool
    let customer: OrderCustomer
    let couponCode: AnyCodable?

    enum CodingKeys: String, CodingKey {
        case orderId = "order_id"
        case totalAmount = "total_amount"
        case isPaid = "is_paid"
        case customer
        case couponCode = "coupon_code"
    }
}

struct OrderCustomer: Codable {
    let fullName: String
    let email: String

    enum CodingKeys: String, CodingKey {
        case fullName = "full_name"
        case email
    }
}

Notice three things. order_id is whole, so it's Int; total_amount is fractional, so it's Double. The nested customer object became its own OrderCustomer type. And coupon_code, which was null in this sample, surfaced as AnyCodable? with no concrete type to infer — a signal you should replace it with the real T? once you've seen a non-null value.

When a Property Becomes Optional

Optionality follows Swift's Optional precisely, and the rule has a subtlety worth internalizing. A property becomes T? when its value was seen as JSON null, or when its key is absent from some elements of a JSON array. That second case is the one that protects you in production. Paste a list like [{"id":1},{"id":1,"name":"x"}] and name is missing from the first element, so the generator marks it name: String? instead of a non-Optional field that throws keyNotFound the moment the server omits it.

A single pasted object has no missing-key signal, so its fields are non-Optional unless a value is literally null. If you want to probe optionality from one sample, wrap it as [{...}] and let the array-fold detection do the work. A field that is only ever null types as AnyCodable? — Swift's Any is not Codable, so there's no type to infer, and the comment flags it for you to fix by hand.

Decoding the Response with JSONDecoder

Once the type is in a .swift file, decoding is one call:

let data = try await URLSession.shared.data(from: url).0
let order = try JSONDecoder().decode(Order.self, from: data)
print(order.totalAmount) // 29.99, fully typed

Because the CodingKeys enum carries the wire keys, you don't need decoder.keyDecodingStrategy = .convertFromSnakeCase — though if you'd rather set that globally and skip per-type enums, that works too, just don't mix the two and then delete a case. One caveat for money: the generator infers Double for any fractional number, but Double is binary floating point and misrepresents values like 0.1. For a currency total, change those fields to Decimal after generating. JSON carries no signal that a number needs exact base-10 precision, so this is a judgment only you can make.

I reached for this on a side project last month, decoding a sports-stats API with roughly 40 fields per record, half of them snake_case and several intermittently null. Hand-writing the model would have taken twenty minutes and at least one typo'd CodingKeys case I'd debug later. Pasting one captured response and reading the generated optionals told me, in about ten seconds, exactly which fields the API treats as guaranteed and which it drops — information that was nowhere in the API's own docs.

Where to Go Next

If you want to inspect or pretty-print the payload before converting, run it through the JSON formatter first so you can see the shape clearly. And if your stack spans more than one language — say a Swift client talking to a TypeScript service — the same JSON drives the JSON to TypeScript interface generator, so both ends stay aligned to one contract.

When you're ready, paste your payload into JSON to Swift Codable, set the root type name, and decide struct versus class based on whether you need value or reference semantics. The generated type is the boring, correct starting point; tighten the Double-to-Decimal and AnyCodable? edges by hand, and you have a model the compiler will defend for you.


Made by Toolora · Updated 2026-06-13