How does synthesised Decodable treat missing keys, nulls, default values and number types?

27.5.9 · L3 · Interview

Predict first

What does this print?

struct User: Codable { var id: Int; var name: String; var age: Int?; var tags: [String] = [] }
func attempt(_ json: String) {
    do {
        let u = try JSONDecoder().decode(User.self, from: Data(json.utf8))
        print("ok", u)
    } catch let e as DecodingError {
        switch e {
        case .keyNotFound(let k, _): print("keyNotFound", k.stringValue)
        case .typeMismatch: print("typeMismatch")
        case .valueNotFound: print("valueNotFound")
        case .dataCorrupted: print("dataCorrupted")
        @unknown default: print("other")
        }
    } catch { print("non-decoding error") }
}
attempt(#"{"id":1,"name":"a","tags":["x"]}"#)
attempt(#"{"id":1,"name":"a"}"#)
attempt(#"{"name":"a","tags":[]}"#)
attempt(#"{"id":1,"name":"a","age":null,"tags":[],"extra":true}"#)
attempt(#"{"id":1,"name":null,"tags":[]}"#)
attempt(#"{"id":1,"name":"a","tags":null}"#)
attempt(#"{"id":"1","name":"a","tags":[]}"#)
attempt(#"{"id":1.0,"name":"a","tags":[]}"#)
attempt(#"{"id":1.5,"name":"a","tags":[]}"#)
let e = JSONEncoder(); e.outputFormatting = .sortedKeys
print(String(data: try e.encode(User(id: 1, name: "a", age: nil)), encoding: .utf8)!)
  • ok ... tags ["x"] / keyNotFound tags / keyNotFound id / ok age nil tags [] / valueNotFound / valueNotFound / typeMismatch / ok (1.0 accepted) / dataCorrupted / {"id":1,"name":"a","tags":[]}
  • ok / ok tags [] / keyNotFound id / ok / valueNotFound / valueNotFound / typeMismatch / ok / ok / {"id":1,"name":"a"}
  • ok / keyNotFound tags / keyNotFound id / ok / keyNotFound name / keyNotFound tags / typeMismatch / typeMismatch / typeMismatch / {"id":1,"name":"a","tags":[]}

Mental model. A form where a printed default is only a suggestion on your side: the other side still rejects an empty box.

The synthesised init(from:) ignores property defaults: var tags: [String] = [] is still required, so a payload without tags throws keyNotFound (and one without id does too). Only optional properties use decodeIfPresent, so a missing or null age becomes nil. A null for a non-optional field (name or tags) is valueNotFound, a string where an Int is expected is typeMismatch, and unknown extra keys are ignored. Encoding omits a nil optional but writes "tags":[].

In this Foundation 1.0 decodes into an Int while 1.5 fails with dataCorrupted (that is Foundation's number handling; do not treat it as language-level). Fix defaults with a custom init(from:) using try c.decodeIfPresent([String].self, forKey: .tags) ?? [], or make the property optional (Q54, Q102).

Defaults are ignored by synthesis, optionals are decodeIfPresent.

Common traps

  • Trusting a default value to fill a missing key. It does not.
  • Treating null and missing as the same for non-optionals. One is keyNotFound, the other valueNotFound.

They might follow up with

  • How do you decode leniently? Q102.

Related questions

  • 1.10.1 How does Codable synthesis work? When do you write CodingKeys or a custom init(from:)?
  • 2.5.4 How do you read a DecodingError, and how do you decode tolerantly?
  • 2.5.1 What does keyDecodingStrategy do, and when does convertFromSnakeCase not match?

← Which method runs through a protocol extension: when is a call…   Where do a nonisolated async function, @concurrent, @MainActor and… →