Diego Lavalle – Swift and Apple Platforms Development

Sep 24, 2024 • Standard Library, REST APIs

Heterogeneous JSON Arrays

DL

As consumers of RESTful APIs we sometimes encounter JSON responses containing mixed arrays. In Swift arrays must have a single content type, so how can we parse a collection of heterogeneous elements? A solution involving enums with associated values may just be the key.

JSON mixed arrays

Because JSON is not strongly typed it can represent lists of objects with diverse structures, like this array mixing passports and driver's licenses:

[
  {
    "country" : "United States",
    "fullName" : "Olivia Rodrigo",
    "passportNumber" : "ABC123"
  },
  {
    "birth" : -63114076800,
    "firstName" : "Olivia",
    "lastName" : "Rodrigo",
    "licenseNumber" : 123456
  }
]

Swift mixed arrays

We can represent each identification type with its own struct while having each of them be automatically decodable and encodable into standard JSON format:

struct Passport: Equatable, Codable {
    var passportNumber: String
    var fullName: String
    var country: String
}

struct DriversLicense: Equatable, Codable {
    var firstName: String
    var lastName: String
    var licenseNumber: Int
    var birth: Date
}

In Swift if we wanted to mix passports and licenses in an array we would have to wrap those types with an enum. Each case of the enumeration should have an associated value of the corresponding identification structure:

enum Identification: Equatable, Codable {
    case passport(Passport), driversLicense(DriversLicense)
}

Simple enough. We even get Codable support out of the box synthesized by the compiler. But what kind of JSON representation would we get our of an identifications list?

Encoding mixed arrays

We declare an array in Swift and encode to see what happens:

let passport = Identification.Passport(passportNumber: "ABC123", fullName: "Olivia Rodrigo", country: "United States")
let driversLicense = Identification.DriversLicense(firstName: "Olivia", lastName: "Rodrigo", licenseNumber: 123456, birth: .distantPast)

let identifications = [Identification.passport(passport), .driversLicense(driversLicense)]

var encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

let data = try encoder.encode(identifications)
print(String(data: data, encoding: .utf8)!)

The resulting document will look exactly like this:

[
  {
    "passport" : {
      "_0" : {
        "country" : "United States",
        "fullName" : "Olivia Rodrigo",
        "passportNumber" : "ABC123"
      }
    }
  },
  {
    "driversLicense" : {
      "_0" : {
        "birth" : -63114076800,
        "firstName" : "Olivia",
        "lastName" : "Rodrigo",
        "licenseNumber" : 123456
      }
    }
  }
]

Note how by default Swift uses each enumeration case as key and then another positional key for the associated value. That is quite a departure from what we wanted to be able to parse initially.

Custom JSON format encoding

We can override the encode(to:) method to get the format we want:

enum Identification: Equatable, Codable { …

    func encode(to encoder: Encoder) throws {
        switch self {
        case .passport(let passport):
            try passport.encode(to: encoder)
        case .driversLicense(let driversLicense):
            try driversLicense.encode(to: encoder)
        }
    } …

Simply forwarding the task to the corresponding associated struct we get an output matching our original JSON example.

Custom JSON decoding

To decode from the same custom format we need to implement the init(from:) initializer. We will arbitrarily try to decode as Passport first, then DriversLicense and if we can't we will throw a type mismatch error:

enum Identification: Equatable, Codable { …

    init(from decoder: Decoder) throws {
        self = if let passport = try? Passport(from: decoder) {
            .passport(passport)
        } else if let driversLicense = try? DriversLicense(from: decoder) {
            .driversLicense(driversLicense)
        } else {
            throw DecodingError.typeMismatch(Self.self, .init(codingPath: decoder.codingPath, debugDescription: "Could not decode as either Passport or DriversLicense."))
        }
    } …

Conclusion

This technique is useful for times when we do not control the format of the data that we need to parse but it's also useful in Swift world as it allows us to combine different unrelated types in one array. Furthermore, we ourselves could want to encode our enums in a more compact way than the compiler's default.