"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
entriesarray 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. CodingKeyscannot appear in a member's signature. It is synthesized while the type's members are being checked, so a helper likestatic func read(_ c: KeyedDecodingContainer<CodingKeys>)fails with "cannot find type 'CodingKeys' in scope". Make the helper generic overK: CodingKey, or declare it as a local function insideinit(from:).