Episode #487

Exploring Witnesses for Encodable

Series: Codable Witnesses

35 minutes
Published on April 29, 2021

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

We start exploring the concept of converting Encodable into an Encoding protocol witness. We discover how to clean up our code and make it fit in with `JSONEncoder`’s existing API. We then break down our example into smaller pieces and discuss how we can leverage pullback and functional composition to build bigger pieces out of smaller ones.

Last time we talked bout how we could take a protocol into a protocol witness in order get some additional flexibility around how protocols are implemented and having multiple strategies that we might want to use.

In this episode we want to explore how we can do this with the Encodable protocol. Let's first start with a quick example.

Codable Review

struct User {
    let id: UUID
    let name: String
    let ageInYears: Int
}

We can make this type Codable like this:

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

We can then create an encoder to encode this as JSON.

let user = User(id: UUID(), name: "Joe Bloggs", ageInYears: 38)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

printJSON(try encoder.encode(user))

This is pretty great, however we are limited to this single encoding structure. If we want to have something different we'll have to come up with something fairly complex, as we only get to implement the encode method once.

Transitioning to a Encoding Witness

First we can start by creating a new struct that is generic over the type we want to encode.

struct Encoding<Input> {

}

Next we can convert the encode method into a closure that accepts the Input as well as an Encoder to use.

    let encode: (Input, Encoder) throws -> Void

To create one of these we just have to specify the block:

let userEncoding = Encoding<User> { user, encoder in 
   // encode as you normally would
}

Then we run into our first problem. How can we call this? Getting an instance of Encoder is not trivial, and is implemented for us when we use JSONEncoder, however this is internal behavior and not something we have access to.

Encoding Proxy

We can get around this problem by utilizing a stand-in type that is itself Encodable and take advantage of the encoder that is passed to it.

struct EncodingProxy<Input>: Encodable {
    let input: Input
    let encoding: Encoding<Input>

    func encode(to encoder: Encoder) throws {
        try encoding.encode(input, encoder)
    }
}

Now we can use this type with our JSONEncoder and everything works.

try encoder.encode(EncodingProxy<User>(user, encoding: userEncoding))

We can clean this up a bit further by hiding the proxy behind a method and give us an API very reminiscent of existing encoder methods.

extension JSONEncoder {
    struct EncodingProxy<Input>: Encodable {
        // ...
    }

    func encode<Input>(_ input: Input, as encoding: Encoding<Input>) throws -> Data {
        let proxy = EncodingProxy(input: input, encoding: encoding)
        return try encode(proxy)
    }
}

Now the callsite is much improved:

try encoder.encode(user, as: userEncoding)

Very nice!

Further Cleanup with Extensions

We can improve this even further by moving our encoding witness, which is currently just a free variable, into an extension.

extension Encoding where Input == User {
    static let defaultEncoding = Encoding { user, encoder in 
       // standard encodable stuff
    }
}

Again, the callsite is improved as the compiler knows we are looking for an Encoding<User> and helpfully offers code completion for us.

try encoder.encode(user, as: .defaultEncoding)

Now it becomes trivially easy to provide different encodings all with a small change that is easily discoverable via the "." completion as seen here.

For instance, let’s say we have a legacy API that we're in the process of migrating away from and it has a different naming strategy.

extension User {
        enum AltCodingKeys: String, CodingKey {
        case id = "ID"
        case name = "UserName"
        case ageInYears = "UserAge"
    }
}

extension Encoding where Input == User {
    static var defaultEncoding = Encoding<User> { (user, encoder) in
        // ...
    }

    static var altEncoding = Encoding<User> { (user, encoder) in
        var container = encoder.container(keyedBy: User.AltCodingKeys.self)
        try container.encode(user.id, forKey: .id)
        try container.encode(user.name, forKey: .name)
        try container.encode(user.ageInYears, forKey: .ageInYears)
    }
}

Now we can use this simply by specifying the different encoding:

try encoder.encode(user, as: .altEncoding)

Breaking it down into smaller encodings

One thing that we could do is start thinking about how this would be applied at a much smaller scale. For instance we could write encodings for all of the primitive types we are using.

If we take a primitive type like Int, this could be encoded as a single value or as a keyed value.

extension Encoding where Input == Int {
    static var singleValue = Encoding<Int> { int, encoder in
        var container = encoder.singleValueContainer()
        try container.encode(int)
    }

    static func keyed<Key: CodingKey>(as key: Key) -> Self {
        .init { int, encoder in
            var container = encoder.container(keyedBy: Key.self)
            try container.encode(int, forKey: key)
        }
    }
}

We could repeat this process for String and UUID as well.

Using Pullback to leverage existing encodings

We can define a pullback function for our Encoding witness so that we can utilize some of these encodings to define new ones to reduce duplication and get more milage out of our existing encodings.

extension Encoding {
    func pullback<NewInput>( _ f: @escaping (NewInput) -> Input) -> Encoding<NewInput> {
        .init { newInput, encoder in
            try self.encode(f(newInput), encoder)
        }
    }
}

With this we could decide to encode a user just as its id property if we wanted:

extension Encoding where Input == User {
    static var id: Self = Encoding<UUID>
        .keyed(as: User.CodingKeys.id)
        .pullback(\.id)
}

So here we leverage the UUID encoding and pull it back to the User type by supplying the \.id key path. So since we know how to turn a User into UUID we can use pullback to turn Encoding<UUID> to Encoding<User>.

Let's say we had a requirement to lowercase the UUID before sending. This could be defined as a separate encoding on UUID but this is really a string operation.

extension Encoding where Input == String {
    // ...

    static func keyed<Key: CodingKey>(as key: Key) -> Self {
        .init { string, encoder in
            var container = encoder.container(keyedBy: Key.self)
            try container.encode(string, forKey: key)
        }
    }

    static func lowercased<Key: CodingKey>(as key: Key) -> Self {
        keyed(as: key)
            .pullback { $0.lowercased() }
    }
}

So this leverages a String encoding, but we use pullback to modify the string and return a new lowercased one.

Again we can extend this to UUID:

extension Encoding where Input == UUID {
    // ...

    static func lowercased<Key: CodingKey>(as key: Key) -> Self {
        Encoding<String>
            .lowercased(as: key)
            .pullback(\.uuidString)
    }
}

Composing multiple encodings

We can define a combine function that takes a list of Encodings and applies all of them.

extension Encoding {
    static func combine(_ encodings: Encoding<Input>...) -> Self {
        .init { input, encoder in
            for encoding in encodings {
                try encoding.encode(input, encoder)
            }
        }
    }
}

With this we can pick and choose encodings to build up a higher level one.

extension Encoding where Input == User {
    static var id: Self = Encoding<UUID>
        .lowercased(as: User.CodingKeys.id)
        .pullback(\.id)

    static var name: Self = Encoding<String>
        .keyed(as: User.CodingKeys.name)
        .pullback(\.name)

    static var ageInYears: Self = Encoding<Int>
        .keyed(as: User.CodingKeys.ageInYears)
        .pullback(\.ageInYears)
}

extension Encoding where Input == User {
    static var defaultEncodingTwo = combine(id, name, ageInYears)
    static var forUpdates = combine(name, ageInYears)
}

This makes it easy to reuse existing encodings to build up new ones, for instance this one forUpdates that omits the id property in the JSON body, since this may be already present in the URL of the request.

This episode uses Swift 5.3, Xcode 12.4.