Skip to content

Instantly share code, notes, and snippets.

@cliss
Last active February 6, 2023 12:31
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cliss/c4c788b6cee2a8f3d307b965c33cdc4c to your computer and use it in GitHub Desktop.
Save cliss/c4c788b6cee2a8f3d307b965c33cdc4c to your computer and use it in GitHub Desktop.
Decoding a heterogenous JSON array
// This is intended to be dropped in a Playground.
import Foundation
let json =
"""
{
"name": "Casey's Corner",
"menu": [
{
"itemType": "drink",
"drinkName": "Dry Vodka Martini"
},
{
"itemType": "drink",
"drinkName": "Jack-and-Diet"
},
{
"itemType": "appetizer",
"appName": "Nachos"
},
{
"itemType": "entree",
"entreeName": "Steak",
"temperature": "Medium Rare"
},
{
"itemType": "entree",
"entreeName": "Caesar Salad"
},
{
"itemType": "entree",
"entreeName": "Grilled Salmon"
}
]
}
"""
struct Drink: Decodable {
let drinkName: String
}
struct Appetizer: Decodable {
let appName: String
}
struct Entree: Decodable {
let entreeName: String
let temperature: String?
}
struct Restaurant: Decodable {
let name: String
let menu: [Any]
// The normal, expected CodingKey definition for this type
enum RestaurantKeys: CodingKey {
case name
case menu
}
// The key we use to decode each menu item's type
enum MenuItemTypeKey: CodingKey {
case itemType
}
// The enumeration that actually matches menu item types;
// note this is **not** a CodingKey
enum MenuItemType: String, Decodable {
case drink
case appetizer
case entree
}
init(from decoder: Decoder) throws {
// Get the decoder for the top-level object
let container = try decoder.container(keyedBy: RestaurantKeys.self)
// Decode the easy stuff: the restaurant's name
self.name = try container.decode(String.self, forKey: .name)
// Create a place to store our menu
var inProgressMenu: [Any] = []
// Get a copy of the array for the purposes of reading the type
var arrayForType = try container.nestedUnkeyedContainer(forKey: .menu)
// Make a copy of this for reading the actual menu items.
var array = arrayForType
// Start reading the menu array
while !arrayForType.isAtEnd {
// Get the object that represents this menu item
let menuItem = try arrayForType.nestedContainer(keyedBy: MenuItemTypeKey.self)
// Get the type from this menu item
let type = try menuItem.decode(MenuItemType.self, forKey: .itemType)
// Based on the type, create the appropriate menu item
// Note we're switching to using `array` rather than `arrayForType`
// because we need our place in the JSON to be back before we started
// reading this menu item.
switch type {
case .drink:
let drink = try array.decode(Drink.self)
inProgressMenu.append(drink)
case .appetizer:
let appetizer = try array.decode(Appetizer.self)
inProgressMenu.append(appetizer)
case .entree:
let entree = try array.decode(Entree.self)
inProgressMenu.append(entree)
}
}
// Set our menu
self.menu = inProgressMenu
}
}
let data = json.data(using: .utf8)!
let restaurant = try! JSONDecoder().decode(Restaurant.self, from: data)
print("\(restaurant.name)")
for item in restaurant.menu {
if let d = item as? Drink {
print(" +-- Drink: \(d.drinkName)")
} else if let a = item as? Appetizer {
print(" +-- Appetizer: \(a.appName)")
} else if let e = item as? Entree {
print(" +-- Entree: \(e.entreeName)")
if let temp = e.temperature {
print(" Temperature: \(temp)")
}
}
}
/* Expected output:
* Casey's Corner
* +-- Drink: Dry Vodka Martini
* +-- Drink: Jack-and-Diet
* +-- Appetizer: Nachos
* +-- Entree: Steak
* Temperature: Medium Rare
* +-- Entree: Caesar Salad
* +-- Entree: Grilled Salmon
*/
@cliss
Copy link
Author

cliss commented Feb 2, 2023

@cliss Love this, thanks for sharing! A change I'd recommend for increased type-safety here would be to narrow the menu items based on a protocol. eg:
[...]
This way you can be confident that only types conforming to MenuItem might show up in your menu, and an exception will be thrown if an unrecognized type is found.

We 100% agree, but I wanted to show an example of completely disparate types, which are possible in JSON.

@dehlen
Copy link

dehlen commented Feb 2, 2023

You could also use an enum I guess to preserve the actual types.
Something like

enum MyEnum {
    case drink(Drink)
    case appetizer(Appetizer)
    ....
}

and then use that like
let menu: [MyEnum]

What do you think about that?

@cliss
Copy link
Author

cliss commented Feb 2, 2023

@dehlen Coincidentally, in the app I'm working on, that's precisely what I did. But that's not the point of the blog post nor this code snippet. :)

@michaelbiggs
Copy link

@cliss Great idea. Never thought to do this.

Another issue I run into often with foreign APIs is handling unexpected values in enums like MenuItemType. If an itemType value is returned that is something other than "drink", "appetizer", or "entree" then the entire Restaurant object fails to decode. That's typically not what I want to happen. So, I write a custom init to handle unknown values.

https://gist.github.com/michaelbiggs/e1c09cbf6d78a0e9f7b32277fa8cda07

@cliss
Copy link
Author

cliss commented Feb 4, 2023

@michaelbiggs For sure.

@melgu
Copy link

melgu commented Feb 6, 2023

Another issue I run into often with foreign APIs is handling unexpected values in enums like MenuItemType. If an itemType value is returned that is something other than "drink", "appetizer", or "entree" then the entire Restaurant object fails to decode. That's typically not what I want to happen. So, I write a custom init to handle unknown values.

If you run into this or similar issues caused by unstable or partially broken APIs fairly often, I can recommend the BetterCodable or EvenBetterCodable packages. With those you can say how to handle unknown or incorrect values, e.g. by returning nil for the specific value or by dropping it from the array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment