Reduce Codable Boilerplate with the Help of Property Wrappers

Sometimes we need to change encoding and decoding logic of one or more fields in a struct. Typically in Swift, if we want to do this, we need to fully define our own init(from:) and encode(to:) implementations.

For this post I will use the example of communicating with a web API that contains multiple Date values but encodes them differently (this is unfortunately not that uncommon).

Consider the JSON body, where both dateOfBirth and created should be represented as Date in Swift.

{
    "name": "Bill",
    "dateOfBirth": "2020-04-23T18:25:43.518Z", 
    "created": 1616363597
}

While we can change Date encoding that JSONEncoder and JSONDecoder use, this is a global switch, so does not allow us to have a mixture of both formats in the same JSON body.

The traditional approach to support it requires us to write a custom encode(to:) and init(from:) methods.

struct UserRecord {
    let name: String
    let dateOfBirth: Date
    let created: Date
}

extension UserRecord: Codable {
    enum CodingKeys: CodingKey {
        case name
        case dateOfBirth
        case created
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(
            keyedBy: CodingKeys.self
        )
        
        self.name = try container.decode(
            String.self,
            forKey: .name
        )
        
        self.dateOfBirth = try container.decode(
            Date.self,
            forKey: .dateOfBirth
        )
        
        let epochCreated = try container.decode(
            TimeInterval.self,
            forKey: .created
        )
        
        self.created = Date(timeIntervalSince1970: epochCreated)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(
            keyedBy: CodingKeys.self
        )
        try container.encode(
            self.name,
            forKey: .name
        )
        try container.encode(
            self.dateOfBirth,
            forKey: .dateOfBirth
        )
        try container.encode(
            self.created.timeIntervalSince1970,
            forKey: .created
        )
    }
}

Such solution gets rather long and error-prone. As soon as we provide some custom encoding for one field, we are required to handwrite the encoding and decoding methods completely. It would be nice if we could still use the automatically provided versions of these methods but just replace how the created date is encoded.

It turns out that we can use Swift property wrapper to reduce boilerplate code. If you’re not familiar with property wrappers, I suggest first taking a look at Property wrappers in Swift article by John Sundell.

If we use a property wrapper that conforms to Codable, it allows the compiler to automatically generate the encoding and decoding methods for our type. The compiler simply calls the encode and decode methods of TimeIntervalSince1970Encoded property wrapper when handling the created field.

struct UserRecord: Codable {
    let name: String
    let dateOfBirth: Date
    
    @TimeIntervalSince1970Encoded
    var created: Date
}

This massively reduces the complexity of our UserRecord and moves the encoding and decoding logic for our date format into a reusable property wrapper.

Here is how we can define TimeIntervalSince1970Encoded property wrapper. In the extension we add custom init(from:) and encode(to:) methods. Since these are called just for the created field they are provided one value, so we use the singleValueContainer to read and write the value.

@propertyWrapper
struct TimeIntervalSince1970Encoded {
    let wrappedValue: Date
}

extension TimeIntervalSince1970Encoded: Codable {
    init(from decoder: Decoder) throws {
        var container = try decoder.singleValueContainer()
        let timeInterval = try container.decode(TimeInterval.self)
        self.wrappedValue = Date(timeIntervalSince1970: timeInterval)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.wrappedValue.timeIntervalSince1970)
    }
}

This method can also be used to provide ways to encode and decode a range of data types. I published an article Encode and Decode Polymorphic Types in Swift that describes how I use it to provide support for polymorphic types.