Articles, podcasts and news about Swift development, by John Sundell.

Customizing how an external Swift type is encoded or decoded

Published on 22 Jun 2021
Discover page available: Codable

For all of its strengths and overall convenience, one downside of Swift’s built-in Codable API is that it doesn’t really offer any standard way to change or otherwise customize how a given type should be encoded or decoded.

While we can always write completely custom coding implementations for the types that we’ve defined ourselves, when working with external types (such as those that ship as part of the standard library), it’s possible to end up with a mismatch between how a given type is expected to be coded and the data format that an app is using.

A coding mismatch

As an example, let’s say that an app that we’re working on contains the following User type, which has a timeZone property that uses Foundation’s built-in TimeZone type:

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var timeZone: TimeZone
}

Now let’s say that we’d like to encode and decode instances of the above User type to and from JSON data that has the following format:

{
    "id": "10CAAD2C-0942-4353-94AE-0319216296CB",
    "name": "John",
    "timeZone": "Europe/Warsaw"
}

So, what’s the problem? Although TimeZone does already conform to Codable out of the box, the way that implementation was written assumes that each such value will always be represented by a dictionary when encoded — and our JSON data instead uses a plain string, which gives us a mismatch.

One potential solution to this problem would of course be to change our JSON data to match the format that TimeZone is expecting (by nesting each time zone identifier within a dictionary), but that’s not always possible. Our app might not be the only client that consumes the above data, or we might be requesting our JSON from a third-party web API that’s beyond our direct control.

Wrapper types

If we instead focus on client-side solutions, one thing that we could do is to wrap the built-in TimeZone type into a custom one that’ll essentially act as a RawRepresentable wrapper — like this:

extension User {
    struct TimeZoneWrapper: RawRepresentable {
        var rawValue: TimeZone
    }
}

RawRepresentable is a simple, but powerful, built-in protocol that all raw value-backed enums implicitly conform to.

Since we’re now dealing with a type that’s under our own control, we can completely customize how we’d like each value to be encoded and decoded. So, in this case, we can start by decoding each time zone identifier as a String, and then use that to initialize an instance of our underlying TimeZone raw value. Then, when encoding, we can simply encode our time zone’s identifier as-is:

extension User.TimeZoneWrapper: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let identifier = try container.decode(String.self)

        guard let timeZone = TimeZone(identifier: identifier) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Unknown time zone '\(identifier)'"
            )
        }

        rawValue = timeZone
    }

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

If we now update our User type from before to use the above TimeZoneWrapper for its timeZone property, then we’ve solved our problem. However, our current solution does come with a cost, in that we now always have to access timeZone.rawValue whenever we want to use the actual TimeZone instance that we’re wrapping.

Now, if we only wanted to use those TimeZone values to access certain properties, then we could solve that using dynamic member lookup, since that would enable us to reference any TimeZone property directly on instances of our TimeZoneWrapper.

However, in this case, we’d likely want to pass our TimeZone values themselves to various date-related system APIs, such as DateFormatter — so let’s see if we can come up with a somewhat more transparent solution.

Using a property wrapper

These days, whenever the phrase “wrapper type” comes up, I like to question whether such a type would perhaps be better implemented as an actual property wrapper instead. After all, wrapping values is exactly what that language feature is for, so let’s see if it could help us solve our problem in this case.

The good news is that converting our TimeZoneWrapper type into a property wrapper just requires us to annotate it with the @propertyWrapper attribute, and to give it a wrappedValue property. In this case, though, let’s also give it a more descriptive name — StringCodedTimeZone — to better signal what this type is actually for. But our Codable implementation can remain exactly the same (apart from replacing rawValue with wrappedValue):

@propertyWrapper
struct StringCodedTimeZone {
    var wrappedValue: TimeZone
}

extension StringCodedTimeZone: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let identifier = try container.decode(String.self)

        guard let timeZone = TimeZone(identifier: identifier) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "Unknown time zone '\(identifier)'"
            )
        }

        wrappedValue = timeZone
    }

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

With the above change in place, we can now let our timeZone property remain a proper TimeZone instance — all that we have to do is to annotate that property with our new wrapper’s attribute, and we’ll once again be able to encode and decode our JSON data without having to change its format in any way:

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    @StringCodedTimeZone var timeZone: TimeZone
}

Pretty neat! If the above property wrapper-based solution looks familiar, it might be because I’ve used in several other articles as well — such as “Ignoring invalid JSON elements when using Codable”, and “Annotating properties with default decoding values”. Property wrappers are, without a doubt, one of my go-to solutions when it comes to Codable customization. While they do require a bit of boilerplate, the fact that they let us customize these kinds of behaviors without having to change the actual types of our properties is incredibly powerful.

A more general-purpose abstraction

Now, if we only needed to solve the above kind of problem once within our entire code base, then we can stop right here. In general, there’s no need to invent new abstractions to solve one-off problems, but let’s say that we instead wanted to come up with a more general-purpose solution that we could use in multiple places across our code base.

Currently, if we were to write multiple instances of our Codable customization solution, we’d end up with a fair amount of boilerplate, since we’d need to write that same (quite verbose!) encoding and decoding code from scratch each time. So let’s address that by introducing a protocol that’ll let us express that a type is codable by transform:

protocol CodableByTransform: Codable {
    associatedtype CodingValue: Codable
    static func transformDecodedValue(_ value: CodingValue) throws -> Self?
    static func transformValueForEncoding(_ value: Self) throws -> CodingValue
}

Fun fact: The above protocol can sort of be seen as a “spiritual successor” to the UnboxableByTransform protocol that Unbox, my pre-Codable JSON decoder, shipped with.

Note how we’re making our new protocol extend Codable itself. That’s a technique that’s often referred to as “protocol specialization”, which essentially lets us create a more specific, tailored version of any protocol by inheriting all of its requirements, extensions, and capabilities.

What’s really cool about that pattern is that it then lets us write default implementations of our base protocol’s requirements. In this case, that’ll let us use our new transformation methods to automatically provide any conforming type with a default Codable implementation — like this:

extension CodableByTransform {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decoded = try container.decode(CodingValue.self)

        guard let value = try Self.transformDecodedValue(decoded) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: """
                Decoding transformation failed for '\(decoded)'
                """
            )
        }

        self = value
    }

    func encode(to encoder: Encoder) throws {
        let encodable = try Self.transformValueForEncoding(self)
        var container = encoder.singleValueContainer()
        try container.encode(encodable)
    }
}

With the above in place, we can now go back to our StringCodedTimeZone property wrapper and make it much simpler. We no longer have to write any specific Codable implementation for it, or any other wrappers like it, and can instead focus on performing the actual transformations to and from its coding representation:

@propertyWrapper
struct StringCodedTimeZone: CodableByTransform {
    static func transformDecodedValue(_ value: String) throws -> Self? {
        TimeZone(identifier: value).map(Self.init)
    }

    static func transformValueForEncoding(_ value: Self) throws -> String {
        value.wrappedValue.identifier
    }

    var wrappedValue: TimeZone
}

We now have a general-purpose abstraction that makes it easy to implement any kind of Codable transformations — either for our own types, or for external types that we’re looking to customize.

Conclusion

While I certainly wish that Codable included more lightweight customization options out of the box, the fact that we can use property wrappers (and other language features) to essentially write small encoding/decoding plugins is really useful. Doing so might require a bit of setup code, but once that’s done, we should be able to tweak any type’s coding process however we’d like.

So, the next time you encounter an external type that doesn’t quite encode or the decode the way you’d like it to, I hope that one of the techniques from this article will come in handy.

Thanks for reading!