How do you make a shared payload that an older widget can still decode after the app adds a new case?

19.10.3 · L5 · Interview

Predict first

What does each decoder do with an unknown "wallet" case and an extra key?

import Foundation

enum Kind: Codable, Equatable {
    case card, loan, unknown(String)
    init(from decoder: Decoder) throws {
        let s = try decoder.singleValueContainer().decode(String.self)
        switch s { case "card": self = .card; case "loan": self = .loan; default: self = .unknown(s) }
    }
    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        switch self { case .card: try c.encode("card"); case .loan: try c.encode("loan"); case .unknown(let s): try c.encode(s) }
    }
}
struct Account: Codable { var kind: Kind; var balance: Int; var note: String? }

enum StrictKind: String, Codable { case card, loan }
struct StrictAccount: Codable { var kind: StrictKind; var balance: Int }

let newer = Data(#"{"kind":"wallet","balance":5,"extraField":1}"#.utf8)
print("strict:", (try? JSONDecoder().decode(StrictAccount.self, from: newer)) == nil ? "failed" : "decoded")
let a = try JSONDecoder().decode(Account.self, from: newer)
print("tolerant:", a.kind, a.balance, a.note as Any)
  • strict fails; tolerant decodes as unknown("wallet") with note nil
  • Both decode
  • Both fail

Mental model. A form that gains a new tick box still has to be readable by the clerk with the old stamp, who ignores what they do not know rather than rejecting the form.

The app and extension update together, but a widget can run older code than the data in the shared container (or newer), and a single new enum case makes a strict decoder fail the whole payload. The check shows both outcomes for JSON containing "kind":"wallet" and an extra field: a plain String-backed enum fails (strict: failed), while an enum with an unknown(String) case decodes (tolerant: unknown("wallet") 5 nil). Unknown keys are ignored by default; a missing key is an error unless the property is optional or you use decodeIfPresent.

Rules that hold up: version the payload (schemaVersion), never remove or repurpose a field, make new fields optional, give enums an unknown case and render it neutrally, write atomically, and decode defensively in the widget so the worst case is a stale or generic entry (Q413). Test with fixture files from the previous release. Not run: a real app and extension of different versions, which needs two installed builds.

An unknown case needs an explicit fallback; extra keys are ignored.

Common traps

  • A strict String enum in shared data. One new case blanks the widget.
  • Sharing the app’s model types directly. They change per release; share a small versioned DTO.

They might follow up with

  • How do I test it? Keep JSON fixtures from each released version and decode them in CI.

Related questions

  • 19.7.2 How do you treat extensions, widgets and intents as a public API of the product: shared modules, data…
  • 19.1.2 How do app groups, shared containers and keychain access groups let an app and its extensions share data, and…
  • 6.8.1 What is an app group, and what can go wrong when an app and an extension share a database?

← What does an app group look like on disk, and what happens with a…