Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

Adding One Field Is a Migration

~16 min · swift-for-apple, codable, persistence, migration

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Nothing deleted the settings file. One new property made the app read it as if it were not there."

The Default Value That Is Not Used

A new build of a family iOS app came up with its engine address reset and a freshly minted device id, as if it had never been configured. The settings file on disk was intact. The only change was one new stored property on the settings struct, with a perfectly sensible default: var deviceName: String = "Spark".

Here is the rule nobody expects: Swift's synthesized Decodable ignores property defaults. For a non-optional property it calls decode, and a key that is missing from the JSON throws DecodingError.keyNotFound — default or not. The old file had no deviceName, so decoding failed, the app fell back to a fresh instance, and on the next save it wrote those defaults over the real file. Optional properties are safe (they decode with decodeIfPresent), which is exactly why this stays hidden until the first non-optional field is added.

Write the Decoder That Tolerates the Past

For any document an app persists and a newer build reads — settings, drafts, queues, caches with meaning — write init(from:) by hand. Read every field with decodeIfPresent, fall back to a fresh instance's value, and wrap each field in try? so one field whose type changed cannot take the rest of the document with it. Then pin it with a test that decodes a literal JSON string written by the previous build, and {} decoding to the defaults. Writing a custom init(from:) suppresses the memberwise initializer, so add back an init().

Three Neighbours of the Same Trap

  • A stricter type is a per-file risk. A family Mac utility adopted a shared hotkey type whose decoder refuses a key code it cannot name. Correct for one hotkey — but a throw from one nested field fails the whole enclosing document, one bad shortcut in a list of saved macros failed the entire array, and the store answered a failed load by writing defaults over the file. Catch the decoding error at the persisted site and fall back per field.
  • Never default a document's identity key. A kit store decoded its root entries array with a default of []. An older app's queue file, stored under a different key, therefore read as an empty outbox — and the first new capture would have overwritten every unsent entry. Default the fields; require the key that proves the file is yours.
  • CodingKeys cannot appear in a member's signature. It is synthesized while the type's members are being checked, so a helper like static func read(_ c: KeyedDecodingContainer<CodingKeys>) fails with "cannot find type 'CodingKeys' in scope". Make the helper generic over K: CodingKey, or declare it as a local function inside init(from:).

Code

Synthesized decoding throws on the old file; the hand-written one keeps it·swift
import Foundation

struct NaiveSettings: Codable {
    var host: String = "127.0.0.1"
    var port: Int = 8500
    var deviceName: String = "Spark"          // added in build 2
}

struct Settings: Codable {
    var host: String = "127.0.0.1"
    var port: Int = 8500
    var deviceName: String = "Spark"

    init() {}                                   // custom init(from:) removes the memberwise one

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let fresh = Settings()
        host = (try? values.decodeIfPresent(String.self, forKey: .host)).flatMap { $0 } ?? fresh.host
        port = (try? values.decodeIfPresent(Int.self, forKey: .port)).flatMap { $0 } ?? fresh.port
        deviceName = (try? values.decodeIfPresent(String.self, forKey: .deviceName)).flatMap { $0 }
            ?? fresh.deviceName
    }
}

let writtenByBuild1 = Data(#"{"host":"engine.example","port":9000}"#.utf8)

do {
    _ = try JSONDecoder().decode(NaiveSettings.self, from: writtenByBuild1)
} catch {
    print(error)   // DecodingError.keyNotFound: Key 'deviceName' not found ...
}

let kept = try JSONDecoder().decode(Settings.self, from: writtenByBuild1)
print(kept.host, kept.port, kept.deviceName)   // engine.example 9000 Spark

External links

Exercise

Reproduce the wipe in Spark: save a Settings file from a version with two fields, add a third non-optional field with a default, and show the naive decode throwing. Then write the tolerant init(from:), and two tests: one decoding the old literal JSON and asserting every old value survived, one decoding {} to the defaults. Finally, find the code path that would have saved defaults over the file and make it refuse.
Hint
Keep the old JSON as a string literal inside the test, exactly as build 1 wrote it. A test that encodes with the new type and decodes it back proves only that the new encoder and decoder agree with each other.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.