Swift
JSON
Decodable Protocol
Nested JSON
Coding

How to decode a nested JSON struct with Swift Decodable protocol?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Nested JSON is common in API responses, and Swift handles it well through the Decodable protocol. The key is to model your Swift types so they match the JSON shape exactly, then use custom coding keys when names differ. Once the structure is clear, decoding complex payloads becomes predictable and easy to test.

Start with a JSON Shape and Mirror It in Swift Types

When decoding fails, the most common reason is a mismatch between JSON nesting and Swift struct nesting. Always inspect the payload first and then define types that mirror each level.

json
1{
2  "id": 42,
3  "profile": {
4    "name": "Ava",
5    "address": {
6      "city": "Toronto",
7      "postal_code": "M5V 2T6"
8    }
9  },
10  "roles": ["admin", "editor"]
11}

Matching Decodable models:

swift
1import Foundation
2
3struct User: Decodable {
4    let id: Int
5    let profile: Profile
6    let roles: [String]
7}
8
9struct Profile: Decodable {
10    let name: String
11    let address: Address
12}
13
14struct Address: Decodable {
15    let city: String
16    let postalCode: String
17
18    enum CodingKeys: String, CodingKey {
19        case city
20        case postalCode = "postal_code"
21    }
22}

Decode it with JSONDecoder:

swift
1let data = jsonString.data(using: .utf8)!
2let decoder = JSONDecoder()
3let user = try decoder.decode(User.self, from: data)
4print(user.profile.address.city)

This works because each nested object maps to a nested Swift type.

Flatten Nested Fields with Custom init(from:)

Sometimes you want a flat Swift model even when JSON is nested. In that case, implement custom decoding and traverse nested containers.

swift
1import Foundation
2
3struct FlatUser: Decodable {
4    let id: Int
5    let name: String
6    let city: String
7
8    enum RootKeys: String, CodingKey {
9        case id
10        case profile
11    }
12
13    enum ProfileKeys: String, CodingKey {
14        case name
15        case address
16    }
17
18    enum AddressKeys: String, CodingKey {
19        case city
20    }
21
22    init(from decoder: Decoder) throws {
23        let root = try decoder.container(keyedBy: RootKeys.self)
24        id = try root.decode(Int.self, forKey: .id)
25
26        let profile = try root.nestedContainer(keyedBy: ProfileKeys.self, forKey: .profile)
27        name = try profile.decode(String.self, forKey: .name)
28
29        let address = try profile.nestedContainer(keyedBy: AddressKeys.self, forKey: .address)
30        city = try address.decode(String.self, forKey: .city)
31    }
32}

Use this pattern when you need a cleaner domain model for view logic or persistence.

Handle Optional Fields, Dates, and Arrays

Real API payloads are rarely perfect. Some fields may be missing, dates may use custom formats, and arrays can be empty.

swift
1import Foundation
2
3struct Event: Decodable {
4    let id: Int
5    let title: String
6    let startsAt: Date?
7    let attendees: [String]
8
9    enum CodingKeys: String, CodingKey {
10        case id, title, attendees
11        case startsAt = "starts_at"
12    }
13}
14
15let decoder = JSONDecoder()
16decoder.dateDecodingStrategy = .iso8601

Prefer decodeIfPresent behavior by declaring optional properties. That avoids hard failures when a field is legitimately absent.

If the backend date format is custom, use a formatter strategy:

swift
1let formatter = DateFormatter()
2formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
3formatter.locale = Locale(identifier: "en_US_POSIX")
4
5decoder.dateDecodingStrategy = .formatted(formatter)

This keeps date parsing explicit and stable across locales.

Add Diagnostics for Fast Debugging

When decoding fails, print the exact DecodingError so you can locate the failing key path quickly.

swift
1do {
2    let user = try decoder.decode(User.self, from: data)
3    print(user)
4} catch let DecodingError.keyNotFound(key, context) {
5    print("Missing key: \(key.stringValue), path: \(context.codingPath)")
6} catch let DecodingError.typeMismatch(type, context) {
7    print("Type mismatch: \(type), path: \(context.codingPath)")
8} catch {
9    print("Other decode error: \(error)")
10}

This is usually faster than guessing which field is wrong.

Common Pitfalls

A common mistake is defining a flat struct for nested JSON without custom decoding logic. Swift then cannot find keys at the expected level and throws key-not-found errors.

Another issue is forgetting CodingKeys when backend names use snake_case. Property names that differ from JSON keys must be mapped explicitly.

Developers also decode strict non-optional fields for data that is not guaranteed by the API. Use optionals for unstable fields and validate downstream where needed.

Summary

  • Mirror JSON nesting in Swift types for reliable Decodable behavior.
  • Use custom coding keys whenever JSON field names differ.
  • Implement init(from:) when you need flattened app models.
  • Configure date decoding strategy to match backend date format.
  • Use detailed DecodingError handling to debug failures quickly.

Course illustration
Course illustration

All Rights Reserved.