VersionedCodable main workflow

A wrapper around Swift’s Codable that allows you to version your Codable type, and rationalise and reason about migrations from older versions of the type. This is especially useful for document types where things often move around.

⚠️ Danger! This is not stable yet! Please think twice before using this in your important production projects. ⚠️

Specifically, VersionedCodable deals with a very specific use case where there is a version key in the encoded object, and it is a sibling of other keys in the object. For example, this:

{
    "version": 1,
    "author": "Anonymous",
    "poem": "An epicure dining at Crewe\nFound a rather large mouse in his stew\nCried the waiter: Don't shout\nAnd wave it about\nOr the rest will be wanting one too!"
}

…would be a representation of this:

struct Poem {
    var author: String
    var poem: String
}

You declare conformance to VersionedCodable like this:

extension Poem: VersionedCodable {
    static let thisVersion: Int? = 2
    
    typealias PreviousVersion = PoemOldVersion
    init(from old: PoemOldVersion) throws {
        self.author = old.author
        self.poem = poem.joined(separator: "\n")
    }
}

You can have as many previous versions as the call stack will allow. When you’ve reached the oldest version and there are no previous versions of the type to try decoding, you make the compiler work and tell the decoder to stop and throw an error by doing this:

struct PoemOldVersion {
    var author: String
    var poem: [String]
}

extension PoemOldVersion: VersionedCodable {
    static let thisVersion: Int? = 1
    
    typealias PreviousVersion = NothingOlder
    // You don't need to provide an initializer here since you've defined `PreviousVersion` as `NothingOlder.`
}

Encoding and decoding

VersionedCodable provides thin wrappers around Swift’s default JSONEncoder.encode(_:) and JSONDecoder.decode(_:from:) functions.

You decode a versioned type like this:

let decoder = JSONDecoder()
try decoder.decode(versioned: Poem.self, from: data) // where `data` contains your old poem

Encoding happens like this:

let encoder = JSONEncoder()
encoder.encode(versioned: myPoem) // where myPoem is of type `Poem` which conforms to `VersionedCodable`

Testing

It is a very good idea to write unit tests that confidence check that you can continue to decode old versions of your types. VersionedCodable provides the types to make this kind of migration easy, but you still need to think carefully about how you map fields between different versions of your types.

Applications

This is mainly intended for situations where you are encoding and decoding complex types such as documents that live in storage somewhere (on someone’s device’s storage, in a database, etc.) In these cases, the format often changes, and decoding logic can often become unwieldy.

VersionedCodable was originally developed for use in Unspool, a photo tagging app for MacOS which is not ready for the public yet.

Still Missing – Wish List

  • Extend Encoder and Decoder to be able to deal with things other than JSON
  • (?) Potentially allow different keypaths to the version field
  • (?) Potentially allow semantically versioned types. (This could be dangerous, though, as semantic versions have a very specific meaning—it’s hard to see how you’d validate that v2.1 only adds to v2 and doesn’t deprecate anything without some kind of static analysis, which is beyond the scope of VersionedCodable.)

GitHub

View Github