Reducing the Codable boilerplate: Property Wrappers to the rescue


Sometimes we need to change the encode/decode 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 dirty web API that contains multiple Date values but encodes them differently (this is unfortunately not that uncommon).


Consider this JSON body:

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

Both dateOfBirth and created should be represented as Date in Swift. While we can change the Date encoding that the JSONEncoder/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 this 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
        )
    }
}


This gets rather long and error-prone since as soon as we provide some custom encoding for one field we are required to handwrite the encoding/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.


Property Wrappers: They can be very useful


If you’re not familiar with Property Wrappers I suggest first taking a quick look at this article by John Sundell.


It turns out that if we use a Property Wrapper that conforms to Codable this allows the compiler to automatically generate the encoding and decoding methods for UserRecord. The Compiler simply calls the encode and decode methods of the 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 dirty date format into a re-useable property wrapper.


The TimeIntervalSince1970Encoded Property Wrapper

@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)
    }
}

In the extension we add custom init(from:) and encode(to:) methods. Since these are called just for the created field they are provided just the value so we use the singleValueContainer to read/write the value.


This can also be used to provide ways to encode and decode a range of data types. I have also published an article that includes how I use this to provide support for polymorphic types.