Episode #488

Codable Witnesses for Decodable

Series: Codable Witnesses

45 minutes
Published on May 7, 2021

This video is only available to subscribers. Get access to this video and 584 others.

Last time we talked about the Encodable protocol. This time let's look at the Decodable protocol. We explore the general concept, then introduce zip and map as utilities to compose smaller decodings into larger ones.

Last time we talked about the Encodable protocol. This time let's look at the Decodable protocol.

A Recap of Decodable

First, a quick recap of how Decodable works.

struct User {
    let id: UUID
    let name: String
    let ageInYears: Int
    let city: String?
    let isAdmin: Bool
}

extension User: Codable {
        enum CodingKeys: String, CodingKey {
            case id
            case name
            case ageInYears = "age"
            case city
            case isAdmin = "is_admin"
      }
}

Given the above implementation we can lean on the default implementation of Decodable and get an object from a JSON representation.

let json = """
    {
        "id": "dea9e624-de07-4f06-abb7-f88e54cd2752",
        "name": "Tim Cook",
        "age": 60
    }
    """
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: json.data(using: .utf8)!)
print(user)

Convert to a Witness

This works, but we often need to customize the behavior, which would lead us to implement the init(from decoder: Decoder) initializer.

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

If you recall from last time, we were able to turn a protocol into a witness by making a generic struct and moving the protocol requirements into closure properties. But this one has an initializer. If you look closely, an User.init is just a static function that returns a user.

struct Decoding<Value> {
    var decode: (Decoder) throws -> Value
}

We can now create a sample decoding witness for a User like this:

let sampleDecoding = Decoding<User> { decoder in
    let container = try decoder.container(keyedBy: User.CodingKeys.self)
    let id = try container.decode(UUID.self, forKey: .id)
    let name = try container.decode(String.self, forKey: .name)
    let age = try container.decode(Int.self, forKey: .ageInYears)
    return User(id: id, name: name, ageInYears: age)
}

Create a Proxy to Access the Decoder

Now that we have a decoding, how do we use it? We need to be able to pass a decoder into this block, but we'll run into similar problems as last time, where we can't easily access this type. We'll employ a similar strategy as last time where we create a proxy to create this for us, then we can just use it.

struct DecodingProxy: Decodable {
    let decoder: Decoder

    init(from decoder: Decoder) throws {
        self.decoder = decoder
    }
}

Now we have access to a decoder. Let's write a method that will let use use any Decodin<T> to decode a type T.

extension TopLevelDecoder {
    func decode<T>(_ input: Input, as decoding: Decoding<T>) throws -> T {
        let proxy = try decode(DecodingProxy.self, from: input)
        return try decoding.decode(proxy.decoder)
    }
}

With that in place we can decode a user using the decoding like this:

let user = try decoder.decode(json.data(using: .utf8)!, as: sampleDecoding)
print(user)

Decomposing into smaller pieces

One thing we can do is create small decodings for each of the properties of the User, then see how we can compose them together. First we'll have to create a way to easily create a decoding for individual types. Luckily we can lean on generics here.

extension Decoding where Value: Decodable {
    static var singleValue: Decoding<Value> {
        .init { decoder in
            let container = try decoder.singleValueContainer()
            return try container.decode(Value.self)
        }
    }

    static func keyed<K: CodingKey>(as key: K) -> Decoding<Value> {
        .init { decoder in
            let container = try decoder.container(keyedBy: K.self)
            return try container.decode(Value.self, forKey: key)
        }
    }

    static var unkeyed: Decoding<Value> {
        .init { decoder in
            var container = try decoder.unkeyedContainer()
            return try container.decode(Value.self)
        }
    }
}

Now we can create decodings for individual properties for our user, providing the coding keys:

let idDecoding = Decoding<UUID>.keyed(as: User.CodingKeys.id)
let nameDecoding = Decoding<String>.keyed(as: User.CodingKeys.name)
let ageDecoding = Decoding<Int>.keyed(as: User.CodingKeys.ageInYears)

Map and Zip

To compose these together we'll have to combine two functional ideas, map and zip.

To zip two decodings A and B we want to return a new Decoding<(A,B)>. This will be useful later. We can start with just two generic arguments:

func zip<A, B>(_ a: Decoding<A>, _ b: Decoding<B>) -> Decoding<(A, B)> {
    .init { decoder in
        try (a.decode(decoder), b.decode(decoder))
    }
}

An example use of this would be: let newDecoding = zip(idDecoding, nameDecoding)

Our user has three properties though, so we'll have to create more of these zip overloads.

func zip<A, B, C>(
    _ a: Decoding<A>,
    _ b: Decoding<B>,
    _ c: Decoding<C>) -> Decoding<(A, B, C)> {
    zip(zip(a, b), c).map { ($0.0, $0.1, $1)}
}

We can continue this pattern to create as many zip variations as we need.

So our combined decoding will be:

let combinedUserDecoding = zip(idDecoding, nameDecoding, ageDecoding)

This gives us a Decoding<(UUID, String, Int)> and we need to pass these off to User.init, which happens to have the shape (UUID, String, Int) -> User. How convenient! The missing piece we need now is to define map:

extension Decoding {
    func map<T>(_ f: @escaping (Value) -> T) -> Decoding<T> {
        .init { decoder in
            try f(self.decode(decoder))
        }
    }
}

This is similar to pullback / contramap from last time, but the arguments are reversed here. This is easier to understand (in my opinion) because you're just mapping the output, or whatever value is decoded.

So we have to pass a function in that takes the same Value as the Decoding has.

let newUserDecoding = combinedUserDecoding.map(User.init)

Neat!

Dealing with Optionals

Let's say our user has two additional properties:

let city: String?
let isAdmin: Bool

city is optional, but isAdmin is not. If either of these are not present in the JSON, we need to be able to handle it.

We can properly deal with city having a Decoding<String?> but only if the JSON has the value "city": null. If the key is missing it will crash. And for isAdmin we'd need to provide a default value.

The first issue can be solved by adding another method on Decoding to deal with optionals:

extension Decoding where Value: Decodable {
   // ...
   static func optional<K: CodingKey>(as key: K) -> Decoding<Value?> {
        .init { decoder in
            let container = try decoder.container(keyedBy: K.self)
            return try container.decodeIfPresent(Value.self, forKey: key)
        }
    }
}

Note that the returned Decoding is of Value?.

The second problem can be solved by chaining on a method that can provide the default value:

extension Decoding {
    func replaceNil<T>(with defaultValue: T) -> Decoding<T> where Value == T? {
        .init { decoder in
            try self.decode(decoder) ?? defaultValue
        }
    }
}

This method is not static, so it serves to add behavior to any decoding where the type is optional.

Now we can add new decodings for these two new properties:

let cityDecoding = Decoding<String>.optional(as: User.CodingKeys.city)
let isAdminDecoding = Decoding<Bool>.optional(as: User.CodingKeys.isAdmin)
    .replaceNil(with: true)

Organizing Decodings into Extensions

It's not ideal that these encodings exist out in the open. We can move them into an extension to make their use more obvious and the code more organized.

extension Decoding where Value == User {
    static let id = Decoding<UUID>.keyed(as: User.CodingKeys.id)
    static let name = Decoding<String>.keyed(as: User.CodingKeys.name)
    static let age = Decoding<Int>.keyed(as: User.CodingKeys.ageInYears)
    static let city = Decoding<String>.optional(as: User.CodingKeys.city)
    static let isAdmin = Decoding<Bool>.optional(as: User.CodingKeys.isAdmin)
        .replaceNil(with: true)

    static let defaultDecoding = zip(id, name, age, city, isAdmin).map(User.init)
} 

With this in place we can now decode a User in arbitrary ways, map any of the decoded values how we see fit, and combine them together.

let user: User = try decoder.decode(json.data(using: .utf8)!, as: .defaultDecoding)
print(user)

This episode uses Swift 5.4, Xcode 12.5.