Back to the dev blog Pitfall report

Forward-compatible Codable: old clients must never destroy what new clients wrote

SharkTTY syncs your saved SSH hosts between devices. While we were adding a new connection type to the app, we asked a question that turned out to have an uncomfortable answer: what happens when a host record written by the new app reaches a device still running the old one? The answer, for a completely ordinary Swift Codable setup, was: the old device's sync silently freezes — and if you "fix" that the obvious way, it gets worse: the old device starts destroying the new device's data. Neither failure needs exotic code to produce. Both come straight from defaults.

Failure one: the enum that refuses the future

The setup is the one every Swift codebase has somewhere:

enum ConnectionMethod: String, Codable { case ssh, mosh }

struct Host: Codable {
    let id: UUID
    var name: String
    var connectionMethod: ConnectionMethod
    // …
}

// somewhere in the sync layer:
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }

A String-backed Codable enum is a closed world: an unknown raw value doesn't fall back, it throws. So the day a newer app writes "connectionMethod": "et", every older reader throws on that one record — and because the whole list decodes as a unit, one record it can't read means no records at all. The try? swallows the error, the import returns early, and the device keeps its stale list forever. No crash, no log line, no banner. Sync just… stops being sync, on every device that hasn't updated yet — which during any real rollout is most of them.

Failure two: the tolerant decoder that destroys data

The reflexive fix is a lenient decode — unknown value, pick a default:

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // tolerant… and lossy

This cures the freeze and plants a landmine. Sync systems re-encode: the old app edits some other host, serializes its whole list, and uploads it — last-writer-wins. But it decoded "et" as .ssh, so that's what it writes back. And the fields it never had properties for — the new record's extra keys — were dropped by Codable at decode time, so they aren't in the upload either. One edit on one out-of-date device, and the newer device's configuration is quietly rewritten into the old shape, account-wide. The freeze at least preserved the data. The tolerant decoder erases it, politely.

The contract: old code must never destroy what new code wrote

Protobuf solved this decades ago with unknown-field preservation; Swift's Codable gives you the tools but not the default. Two moves recreate it.

Store the wire truth, expose the typed view. Each enum field keeps its raw string as the stored property; the typed enum becomes a computed view with a display-only fallback. Reading an unknown value degrades gracefully; writing it back reproduces it exactly. Only a deliberate user edit — the setter — overwrites it:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // fallback for DISPLAY
    set { connectionMethodRaw = newValue.rawValue }                  // deliberate edits win
}

Keep the keys you don't understand. A second pass with a string-keyed container collects every key that isn't yours into a bag of JSONValue (a small seven-case enum: null, bool, int, double, string, array, object — decode Int64 before Double, or your ports come back as floats). encode(to:) writes your known fields, then replays the bag verbatim:

// decode: collect the future
let known = Set(CodingKeys.allCases.map(\.rawValue))
let any = try decoder.container(keyedBy: AnyCodingKey.self)
for key in any.allKeys where !known.contains(key.stringValue) {
    unknownFields[key.stringValue] = try any.decode(JSONValue.self, forKey: key)
}

// encode: give the future back, untouched
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
    try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}

With both in place, an old client is a safe conduit: it displays what it can, round-trips what it can't, and a mixed-version fleet — the normal state of the world for weeks around every release — stops being a hazard.

Pinning it down with tests

Three tests carry the whole contract. A round-trip fixture: a record from a “future” version — unknown enum value, unknown scalar, unknown nested object — must decode without error and re-encode with every original key intact. A deliberate-edit test: setting the typed property must overwrite the raw value while the unknown keys still survive. And a golden test: a record with only known fields must encode byte-identically to the old code's output — no unknownFields key, no …Raw storage names leaking onto the wire. That last one is what lets you refactor the internals without silently forking the format.

One honest caveat to end on: none of this repairs clients that already shipped without it. Their decoders still refuse the future. The migration is boring and unavoidable — land the tolerant, preserving decoders first, let them roll out, and only then let the new values start flowing. Schema evolution is a sequencing problem wearing a serialization costume.

This hardening ships across SharkTTY's sync layer in the next update. Disagree with the approach, or been burned differently? The feedback board is open.