⌘ kean.blog

Codable: Tips and Tricks

  

Updates

  • Nov 21, 2021. Add references to the new Codable features and rework the existing tips.

I’ve just finished migrating our app to Codable and I’d like to share some of the tips and tricks that I’ve come up with along the way.

You can download a Swift Playground with all of the code from this article.

Xcode screenshot showing Codable example

Apple introduced Codable in Swift 4 with a motivation to replace old NSCoding APIs. Unlike NSCoding it has first-class JSON support making it a great option not just for persisting data but also for decoding JSON responses from web APIs.

Codable is great for its intended purpose of NSCoding replacement. If you just need to encode and decode some local data that you have full control over you might even be able to take advantage of automatic encoding and decoding.

In the real world though things get complicated very quickly. Building a fault tolerant system capable of dealing with all the quirks of the external JSON is a challenge.

One of the major downsides of Codable is that as soon as you need custom decoding logic - even for a single key - you have to provide custom everything: manually define all the coding keys, and implementing an entire init(from decoder: Decoder) throws initializer by hand. This, of course, isn’t ideal. But fortunately, there are a few tricks that can make working with Codable a bit easier.

1. Safely Decoding Arrays #

Let’s say you need to load and display a list of posts in your app. Every Post has an id (required), title (required), and subtitle (optional).

struct Post: Decodable {
    let id: Id<Post> // More about this type later.
    let title: String
    let subtitle: String?
}

Swift compiler automatically generated Decodable implementation for this struct, taking into account what types are optional. Let’s try to decode an array of posts.

[
    {
        "id": "pos_1",
        "title": "Codable: Tips and Tricks"
    },
    {
        "id": "pos_2"
    }
]
do {
    let posts = try JSONDecoder().decode([Post].self, from: json.data(using: .utf8)!)
} catch {
    print(error)
    // prints "No value associated with key title (\"title\")."
}

It throws an error: .keyNotFound. The second post object is missing a required title field, so that makes sense. Swift provides a complete error report using DecodingError which is extremely useful.

But what if you don’t want a single corrupted post to prevent you from displaying an entire page of otherwise perfectly valid posts. I use a special Safe<T> type that allows me to safely decode an object. If it encounters an error during decoding, it fails safely and sends a report to the development team:

public struct Safe<Base: Decodable>: Decodable {
    public let value: Base?

    public init(from decoder: Decoder) throws {
        do {
            let container = try decoder.singleValueContainer()
            self.value = try container.decode(Base.self)
        } catch {
            assertionFailure("ERROR: \(error)")
            // TODO: automatically send a report about a corrupted data
            self.value = nil
        }
    }
}

Now I can indicate that I don’t want to stop decoding in case of a single corrupted element:

do {
    let posts = try JSONDecoder().decode([Safe<Post>].self, from: json.data(using: .utf8)!)
    print(posts[0].value!.title)    // prints "Codable: Tips and Tricks"
    print(posts[1].value)           // prints "nil"
} catch {
    print(error)
}

Keep in mind that decode([Safe<Post>].self, from:... is still going to throw an error if the data doesn’t contain an array of contain something other than an array.

Ignoring errors is not always the best strategy. For example, if you are displaying bank accounts and the backend suddenly starts sending invalid data for one of them, it’s might be better to show an error than cause a heart attack!

2. Id Type and a Single Value Container #

In the previous example, I used a special Id<Post> type. It gets parametrized with a generic parameter Entity which isn’t used by the Id itself but is used by the compiler when comparing different Id. This way it can ensure that you can’t accidentally pass Id<Media> where Id<Image> is expected.

Generic types like this are sometimes referred to as phantom types. You can learn more about them in Three Use-Cases of Phantom Types.

The Id type itself is very simple, it’s just a wrapper for a raw String:

public struct Id<Entity>: Hashable {
    public let raw: String
    public init(_ raw: String) {
        self.raw = raw
    }
        
    public var hashValue: Int {
         raw.hashValue
    }
    
    public static func ==(lhs: Id, rhs: Id) -> Bool {
        return lhs.raw == rhs.raw
    }
}

Adding Codable conformance to it is a bit more tricky. It requires a special SingleValueEncodingContainer type – a container that can support the storage and direct encoding of a single non-keyed value.

extension Id: Codable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let raw = try container.decode(String.self)
        if raw.isEmpty {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Cannot initialize Id from an empty string"
            )
        }
        self.init(raw)
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(raw)
    }
}

This gets the job done also ensuring that the ID has a non-empty value.

3. Safely Decoding Enums #

Swift has great support for decoding (and encoding) enums and Swift 5.5, it even generates conformances for enums with associated types. Often all you need to do is declare a Decodable conformance synthesized automatically by a compiler (the enum raw type must be either String or Int).

Suppose you’re building a system that displays all your devices on a map and you modelled your types the following way:

enum System: String, Decodable {
    case ios, macos, tvos, watchos
}

struct Location: Decodable {
    let latitude: Double
    let longitude: Double
}

final class Device: Decodable {
    let location: Location
    let system: System
}

Now, what if more systems are added in the future? The product decision might be to display the “unknown” devices on a map but indicate that the app update is needed. But how should you go about modeling this in the app? By default Swift throws a .dataCorrupted error if it encounters an unknown enum value:

{
    "location": {
        "latitude": 37.3317,
        "longitude": 122.0302
    },
    "system": "caros"
}
do {
    let device = try JSONDecoder().decode(Device.self, from: json.data(using: .utf8)!)
} catch {
    print(error)
    // Prints "Cannot initialize System from invalid String value caros"
}

You can make a system optional or add an explicit .unknown type. And then the most straightforward way to decode system safely is by implementing a custom init(from decoder: Decoder) throws initializer:

final class Device: Decodable {
    let location: Location
    let system: System?

    init(from decoder: Decoder) throws {
        let map = try decoder.container(keyedBy: CodingKeys.self)
        self.location = try map.decode(Location.self, forKey: .location)
        self.system = try? map.decode(System.self, forKey: .system)
    }

    private enum CodingKeys: CodingKey {
        case location
        case system
    }
} 

You could also use the Safe type introduced in one of the previous sections. But both of these options are not ideal because they ignore all the potential problems with the system value. This means that even corrupted data – e.g. a missing key, a number 123, null, an empty object – gets decoded to nil (or .unknown). A more precise way to say “decode unknown strings as nil” would be:

self.system = System(rawValue: try map.decode(String.self, forKey: .system))

4. Less Verbose Decoding #

In the previous example, I used a custom initializer and it turned out pretty verbose. Fortunately, there are a few ways to make it more concise.

4.1. Implitic Type Parameters #

The first obvious thing is to get rid of the explicit type parameters.

extension KeyedDecodingContainer {
    public func decode<T: Decodable>(_ key: Key, as type: T.Type = T.self) throws -> T {
        try self.decode(T.self, forKey: key)
    }

    public func decodeIfPresent<T: Decodable>(_ key: KeyedDecodingContainer.Key) throws -> T? {
        try decodeIfPresent(T.self, forKey: key)
    }
}

Let’s go back to our Post example and extend it with an optional webURL property. If you try to decode the data posted below, you’ll get a .dataCorrupted error with an underlying error: “Invalid URL string.”.

{
    "id": "pos_1",
    "title": "Codable: Tips and Tricks",
    "webURL": "http://google.com/🤬"
}

Let’s implement a custom initializer to ignore this error and also take the new convenience methods for a spin.

final  Post: Decodable {
    let id: Id<Post>
    let title: String
    let webURL: URL?

    init(from decoder: Decoder) throws {
        let map = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try map.decode(.id)
        self.title = try map.decode(.title)
        self.webURL = try? map.decode(.webURL)
    }

    private enum CodingKeys: CodingKey {
        case id
        case title
        case webURL
    }
}

This works. But as one of the Swift developers points out in a comment to SR-6063 there are a few places when explicitly specifying the generic type is necessary. Well, you can always fallback to the built-in methods.

This new approach is great, but this way all the errors are ignored again. What would be ideal is to automatically send a report to the server about the corrupted data. You can do that pretty easily with a few more convenience methods for decoding values.

Before I wrap up this section, let me also share one more extension that I added:

extension JSONDecoder {
    public func decodeSafelyArray<T: Decodable>(of type: T.Type, from data: Data) -> [T] {
        guard let array = try? decode([Decoded<T>].self, from: data) else { return [] }
        return array.flatMap { $0.raw }
    }
}

// Usage (creates a plain array of posts – [Post])
let posts = JSONDecoder().decodeSafelyArray(of: Post.self, from: data)

4.2. Separate JSON Scheme #

Another approach that I really like is defining a separate type to take advantage of automatic decoding, and then map it to your entity.

final struct Post: Decodable {
    let id: Id<Post>
    let title: String
    let webURL: URL?

    init(from decoder: Decoder) throws {
        let map = try PostScheme(from: decoder)
        self.id = map.postIdentifier
        self.title = map.title
        self.webURL = map.webURL?.value
    }

    private struct PostJSON: Decodable {
        let postIdentifier: Id<Post>
        let title: String
        let webURL: Safe<URL>?
    }
}

What I like about this approach is that it’s declarative. It still uses automatic decoding while giving you a ton of flexibility. It’s a great way to map the data to the format more suitable for your app.

5. Encoding Patch Parameters #

And for the final type - the PATCH HTTP method. In REST, it is used to send a set of changes described to be applied to the entity.

  • The keys not present in the request are ignored
  • If the key is present, the value gets updated (null deletes a value)

The way I implemented this in the app is with a new Parameter type. Here’s how it looks like:

public enum Parameter<Base: Codable>: Encodable {
    case null // parameter set to `null`
    case value(Base)

    public init(_ value: Base?) {
        self = value.map(Parameter.value) ?? .null
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .null: try container.encodeNil()
        case let .value(value): try container.encode(value)
        }
    }
}

And the example usage:

struct PatchParameters: Encodable {
    let name: Parameter<String>?
}

func encoded(_ params: PatchParameters) -> String {
    let data = try! JSONEncoder().encode(params)
    return String(data: data, encoding: .utf8)!
}

encoded(PatchParameters(name: nil))
// prints "{}"

encoded(PatchParameters(name: .null))
//print "{"name":null}"

encoded(PatchParameters(name: .value("Alex")))
//print "{"name":"Alex"}"

Sweet, exactly what I wanted.

Final Thoughts #

Apple got a lot of things right with Codable, and I hope to see more improvements in the future!