Swift Heterogeneous Codable Array

Quite the mouthful of a title but nevertheless it's a typical problem. Receiving data from a remote service is super common but it's not always obvious how to represent our data in a strongly typed language like Swift.

Problem outline

Let's imagine an example where we are using a remote service that returns a collection of shapes. We have structs within our app that represent the various shapes and we want to parse the JSON objects into these native types.

Here's the struct definitions:

struct Square: Codable {
    let length: Double

    var area: Double {
        return length * length
    }
}

struct Rectangle: Codable {
    let width: Double
    let height: Double

    var area: Double {
        return width * height
    }
}

and our JSON feed looks like this:

{
  "shapes" : [
    {
      "type" : "square",
      "attributes" : {
        "length" : 200
      }
    },
    {
      "type" : "rectangle",
      "attributes" : {
        "width" : 200,
        "height" : 300
      }
    }
  ]
}

A first attempt at a solution

Our initial attempt to parse this might end up creating a new type called FeedShape that has optional attributes for every possible shape. We can use JSONDecoder to parse the feed. Then as a second step we can map the shapes into our native types. That might look like this:

struct Feed: Decodable {
    let shapes: [FeedShape]

    struct FeedShape: Decodable {
        let type: String
        let attributes: Attributes

        struct Attributes: Decodable {
            let width: Double?
            let height: Double?
            let length: Double?
        }
    }
}

let feedShapes = try JSONDecoder().decode(Feed.self, from: json).shapes

var squares    = [Square]()
var rectangles = [Rectangle]()

for feedShape in feedShapes {
    if feedShape.type == "square", let length = feedShape.attributes.length {
        squares.append(.init(length: length))
    } else if feedShape.type == "rectangle", let width = feedShape.attributes.width, let height = feedShape.attributes.height {
        rectangles.append(.init(width: width, height: height))
    }
}

Whilst this will work it's really not pleasant to write/maintain or use.

There are many issues with the above:

1) Optionals everywhere

Every time a new type is added that we can support within the app our Attributes struct will grow. It's a code smell for there to be a type where most of its properties will be nil.

2) Manually checking requirements before creating objects

In order to create the concrete types we have to manually check the type property and that all the other required properties have been decoded. The code to do this is not easy to read, this fact is painful because this code ultimately is the source of truth for how to decode these objects. Looking at the current Attributes type we can see that all it's properties are Double? - it could be quite easy to copy and paste the property checking logic and end up trying to use the wrong key across multiple types.

3) Stringly typed code

To create the concrete types we need to check the type property against a String. Having repeated strings scattered throughout a codebase is generally bad form just asking for typos and refactoring issues.

4) We've lost the order

Due to the way the above is modelled there is no current way to keep track of the order in which the concrete types should actually appear.

5) It's not taking advantage of our Codable types

Our Square and Rectangle types already conform to Codable so it would be beneficial to make use of this rather than manually creating our types. Using Codable also resolves the poor documentation issue raised in 2 because for simple types the compiler will generate the Codable implementation just from the type declaration.


Can we do better?

To make an improvement that addresses 2, 4 and 5 we can deserialise our collection to an [Any] type. This requires a custom implementation of Decodable in which we loop over the items and delegate the decoding to the Shape/Rectangle decodable implementations. The code looks like the following:

struct Feed: Decodable {
    let shapes: [Any]
    
    init(from decoder: Decoder) throws {
        var container = try decoder.container(keyedBy: CodingKeys.self).nestedUnkeyedContainer(forKey: .shapes)
        
        var shapes = [Any]()
        
        while !container.isAtEnd {
            let itemContainer = try container.nestedContainer(keyedBy: CodingKeys.self)
            
            switch try itemContainer.decode(String.self, forKey: .type) {
            case "square":    shapes.append(try itemContainer.decode(Square.self, forKey: .attributes))
            case "rectangle": shapes.append(try itemContainer.decode(Rectangle.self, forKey: .attributes))
            default: fatalError("Unknown type")
            }
        }

        self.shapes = shapes
    }
    
    private enum CodingKeys: String, CodingKey {
        case attributes
        case shapes
        case type
    }
}

Although this is an improvement we still have stringly typed code and we've introduced another issue. Now we have an [Any] type. The use of Any can be a smell that we are not modelling things as well as we can do. This can be seen when we come to use the collection later on - we'll be forced to do lot's of type checking at run time. Type checking at run time is less desirable than at compile time because it means our app might crash in the wild as opposed to simply not compiling. There is also the issue that there is nothing at compile time that forces us to handle all cases e.g. I could very easily write code like this

shapes.forEach { item in
    if let square = item as? Square {
        // do square stuff
    }
    
    // Ooops I forgot to handle Rectangle's or any other new type we add
}

Can we do better still?

The issues above can all be addressed.

In order to resolve 5 we need to create an array that can contain one type or another. Enums are the mechanism for creating the sum type we need, which gives us:

enum Content {
    case square(Square)
    case rectangle(Rectangle)
}

Issues 1, 2 and 5 can all be resolved by taking advantage of the fact that our types are already Codable. If we make our new Content type Decodable we can check the type we are dealing with and then delegate the decoding to the appropriate Square/Rectangle decodable implementation.

NB: This is probably the trickiest transformation to follow, especially if you've not worked with custom decoding before. Just google any API you don't recognise.

enum Content: Decodable {
    case square(Square)
    case rectangle(Rectangle)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        switch try container.decode(String.self, forKey: .type) {
        case "square":    self = .square(try container.decode(Square.self, forKey: .attributes))
        case "rectangle": self = .rectangle(try container.decode(Rectangle.self, forKey: .attributes))
        default: fatalError("Unknown type")
        }
    }

    private enum CodingKeys: String, CodingKey {
        case attributes
        case type
    }
}

Finally to resolve 3 we can leverage the exhaustive checking of switch statements on enums.

enum Content: Decodable {
    case square(Square)
    case rectangle(Rectangle)

    var unassociated: Unassociated {
        switch self {
        case .square:    return .square
        case .rectangle: return .rectangle
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        switch try container.decode(String.self, forKey: .type) {
        case Unassociated.square.rawValue:    self = .square(try container.decode(Square.self, forKey: .attributes))
        case Unassociated.rectangle.rawValue: self = .rectangle(try container.decode(Rectangle.self, forKey: .attributes))
        default: fatalError("Unknown type")
        }
    }

    enum Unassociated: String {
        case square
        case rectangle
    }

    private enum CodingKeys: String, CodingKey {
        case attributes
        case type
    }
}

By reifying the type property from a String to a real Swift type we convert run time bugs into compile time issues, which is always a great goal to aim for.

NB: The Unassociated enum might look a little odd but it helps us model the types in one concrete place rather than having strings scattered throughout our callsites. It's also quite useful in situations where you want to check the type of something without resorting to case syntax e.g. if we want to filter our collection to only Squares then this is one line with our new Unassociated type:

shapes.filter { $0.unassociated == .square }

without the unassociated type this ends up being something like

shapes.filter {
    if case .square = $0 {
        return true
    } else {
        return false
    }
}

// or

shapes.filter {
    switch $0 {
    case .square: return true
    default:      return false
    }
}

Conclusion

The two key takeaways here are

Removing optionality, reifying types and using compiler generated code are great ways of simplifying our code. In some cases this also helps move runtime crashes into compile time issues, which is generally making our code safer. The benefits here are great and it shows that it's really worth taking time to model your data correctly and then use tools like Codable to munge between representations.

The title was a little bit of a lie as I only walked through the Decodable part of Codable (see the listing below for the Encodable implementation).


Full code listing

The full code to throw into a playground ends up looking like this:

//: Playground - noun: a place where people can play

import Foundation

let json = Data("""
{
  "shapes" : [
    {
      "type" : "square",
      "attributes" : {
        "length" : 200
      }
    },
    {
      "type" : "rectangle",
      "attributes" : {
        "width" : 200,
        "height" : 300
      }
    }
  ]
}
""".utf8)

struct Square: Codable {
    let length: Double

    var area: Double {
        return length * length
    }
}

struct Rectangle: Codable {
    let width: Double
    let height: Double

    var area: Double {
        return width * height
    }
}

struct Feed: Codable {
    let shapes: [Content]

    enum Content: Codable {
        case square(Square)
        case rectangle(Rectangle)

        var unassociated: Unassociated {
            switch self {
            case .square:    return .square
            case .rectangle: return .rectangle
            }
        }

        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)

            switch try container.decode(String.self, forKey: .type) {
            case Unassociated.square.rawValue:    self = .square(try container.decode(Square.self, forKey: .attributes))
            case Unassociated.rectangle.rawValue: self = .rectangle(try container.decode(Rectangle.self, forKey: .attributes))
            default: fatalError("Unknown type")
            }
        }

        func encode(to encoder: Encoder) throws {
            var container = encoder.container(keyedBy: CodingKeys.self)

            switch self {
            case .square(let square):       try container.encode(square, forKey: .attributes)
            case .rectangle(let rectangle): try container.encode(rectangle, forKey: .attributes)
            }

            try container.encode(unassociated.rawValue, forKey: .type)
        }

        enum Unassociated: String {
            case square
            case rectangle
        }

        private enum CodingKeys: String, CodingKey {
            case attributes
            case type
        }
    }
}

let feed = try JSONDecoder().decode(Feed.self, from: json)
print(feed)

let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted
print(String(data: try jsonEncoder.encode(feed), encoding: .utf8)!)