Design Footnotes for CodableWrappers


Not looking for so many nerdy details or don't know what this is all about? Head over here for a more general breakdown of the library and the problems it solves.

Introduction

Custom Serialization with Codable is currently either up to the (en/de)coder being used, or requires custom implementations.

When Property Wrappers was announced I realized, (after some messing around and several rabbit trails), they can customize the Codable implementation for an individual property.

Advantages of Property Wrappers

Replacing the current approach with a Property Wrapper gets a lot of advantages from being implemented at the Property level, rather than the (en/de)coder or Type level:

  • Declare once for all Encoders and Decoders. (JSONEncoder, PropertyListDecoder, or any 3rd party implementations)
  • Custom (de/en)coding without overriding encode(to: Encoder) or init(with decoder) for your whole Type to customize one property
  • Multiple (de/en)coding strategies for different properties within the same Type and/or child Types

Using an Attribute also results in an improved Design:

  • It's declarative at the point of use. No custom implementation code, no options to set on your (en/de)coders
  • It's self-documenting. The serialization is defined by the property, so comments are no longer needed to say as much and there's no de-synchronization between documented serialization and (en/de)coders
  • It can be reused in any Type without writing any additional code.

I will be using this approach whenever viable, so a library that simplifies this process seemed like a good idea!

However there are some strict limitations of Codable and Property Wrappers. Finding ways around these road blocks was an exercise in iteration and creativity.

Codable Property Wrapper Constraints

Codable and Property Wrappers are focussed on solving specific problems in specific ways. This is a good approach, and can also limit what's possible.

Codable Constraints

Since Decodable uses init for it's decoding, any customization must be accessible in a static context. This means customized decoding with Property Wrappers requires either separate implementations for each wrapper (no thank you), or a way to define that customization at the declaration point. This leaves these constraints:

  • Custom Decoding must be static
  • That customization needs to be injected at the declaration point

Property Wrapper Constraints

Since a Property Wrapper defines whether it's wrapped Property is mutable, both mutable and immutable versions are needed. To match Codable's API, separate Encodable and Decodable versions should be available. Filling these 2 constraints require 6 different Property Wrappers for each customization.

  • Encodable
  • Decodable
  • Codable
  • EncodableMutable
  • DecodableMutable
  • CodableMutable

Given the overhead required for many wrappers, the design should abstract away as much of the implementation as possible.

Final Design Constraints

  1. The decoding must be done statically
  2. That capability must be accessible within the Property Wrapper
  3. The Codable implementation can't rely on a separate Property Wrapper implementations
  4. It must easily support the 6 different wrapper types

Working Through Some Things

The first constraint can be solved by writing static versions of (En/De)codable.

*Note* the following implementations are limited to Codable for the sake of brevity. The actual implementations also include separate Encoder and Decoder variants

// Protocol
public protocol StaticCoder: Codable {
    associatedtype CodingType: Encodable
    /// Mirror of `Encodable`'s `encode(to: Encoder)` but in a static context
    static func encode(value: CodingType, to encoder: Encoder) throws
    /// Mirror of `Decodable`'s `init(from: Decoder)` but in a static context
    static func decode(from decoder: Decoder) throws -> CodingType
}

// Example Implementation
public struct Base64DataCoder: StaticCoder {
    private init() { }

    public static func decode(from decoder: Decoder) throws -> Data {
        let stringValue = try String(from: decoder)

        guard let value = Data.init(base64Encoded: stringValue) else {
            // throw an error
        }
        return value
    }

    public static func encode(value: Data, to encoder: Encoder) throws {
        try value.base64EncodedString().encode(to: encoder)
    }
}

Generics are a good candidate for #2 and #3. They allow a StaticCoder to be passed in at the declaration point.

// Wrapper
@propertyWrapper
public struct CodingUses<CustomCoder: StaticCoder>: Codable {

    public let wrappedValue: CustomCoder.CodingType
    public init(wrappedValue: CustomCoder.CodingType) {
        self.wrappedValue = wrappedValue
    }

    public init(from decoder: Decoder) throws {
        self.init(wrappedValue: try CustomCoder.decode(from: decoder))
    }

    public func encode(to encoder: Encoder) throws {
        try CustomCoder.encode(value: wrappedValue, to: encoder)
    }
}

// Usage
struct MyType: Codable {
    @CustomCoding<Base64DataCoder>
    var myBase64Data: Data
}

For #4, the Codable implementation can be put into protocols with default implementations.

public protocol StaticCodingWrapper: Codable {
    associatedtype CustomCoder: StaticCoder
}

extension StaticCodingWrapper {
    public init(from decoder: Decoder) throws {
        self.init(wrappedValue: try CustomCoder.decode(from: decoder))
    }

    public func encode(to encoder: Encoder) throws {
        try CustomCoder.encode(value: wrappedValue, to: encoder)
    }
}

And...Voilà!

The Property Wrapper can now be cut down to a manageable size enabling the simplest implementation possible for each of the 6 required versions with very little copy-pasting.

@propertyWrapper
public struct CodingUses<CustomCoder: StaticCoder>: StaticCodingWrapper {

    public let wrappedValue: CustomCoder.CodingType
    public init(wrappedValue: CustomCoder.CodingType) {
        self.wrappedValue = wrappedValue
    }
}

@propertyWrapper
public struct CodingUsesMutable<CustomCoder: StaticCoder>: StaticCodingWrapper {

    public var wrappedValue: CustomCoder.CodingType
    public init(wrappedValue: CustomCoder.CodingType) {
        self.wrappedValue = wrappedValue
    }
}

Adding More Wrappers

An (admittedly unintended 😁) side effect of this approach also means a new Property Wrapper can be added with a typealias so rather than dozens of implementations each new Wrapper can be written in one line!

// Wrappers

/// Encode this immutable `Data` Property as a Base64 encoded String
typealias Base64Encoding = EncodingUses<Base64DataStaticCoder>
/// Decode this immutable `Data` Property as a Base64 encoded String
typealias Base64Decoding = DecodingUses<Base64DataStaticCoder>
/// (En/De)code this immutable `Data` Property as a Base64 encoded String
typealias Base64Coding = CodingUses<Base64DataStaticCoder>

/// Encode this immutable `Data` Property as a Base64 encoded String
typealias Base64EncodingMutable = EncodingUsesMutable<Base64DataStaticCoder>
/// Decode this immutable `Data` Property as a Base64 encoded String
typealias Base64DecodingMutable = DecodingUsesMutable<Base64DataStaticCoder>
/// (En/De)code this immutable `Data` Property as a Base64 encoded String
typealias Base64CodingMutable = CodingUsesMutable<Base64DataStaticCoder>

// Usage
struct MyType: Codable {
    @Base64Coding
    var myBase64Data: Data
}

Custom Wrappers

Although a "full" implementation requires all 6 wrappers from a library point of view, the structure is easily extendable and most custom versions would only need one. So the simplest version of a custom coding is quite short.

struct NanosecondsSince9170Coder: StaticCoder {

    static func decode(from decoder: Decoder) throws -> Date {
        let nanoSeconds = try Double(from: decoder)
        let seconds = nanoSeconds * 0.000000001
        return Date(secondsSince1970: seconds)
    }

    static func encode(value: Date, to encoder: Encoder) throws {
        let nanoSeconds = value.secondsSince1970 / 0.000000001
        return try nanoSeconds.encode(to: encoder)
    }
}

typealias NanosecondsSince9170Coding = CodingUses<NanosecondsSince9170Coder>

This can be used as...

struct MyType: Codable {
    @NanosecondsSince9170Coding
    var myData: Date // Now uses the NanosecondsSince9170Coder for serialization
}

Wrapping Up

Swift is still quite young as programming languages go and is just now stabilizing. It's already enabled/popularized new design patterns for Apple developers and, in my experience, greatly reduced many common bugs and crashes compared to it's predecessors. There are (of course) still plenty of pain points, but the big growing pains are seemingly behind us and features like Property Wrappers being added makes me optimistic about the future of the language.

Next
Next

Making Custom Serialization a Breeze in Swift 5.1