Articles
Swift Codable Interview Questions: Missing Keys, Nulls, and Schema Evolution
Practice Swift Codable interview questions with missing-key and null traces, explicit defaults, three-state PATCH decoding, and schema migration examples.
An API starts sending "displayName": null. An iOS client keeps decoding successfully, but an update screen now clears a name when the server merely omitted that field. The model has a String?, so both payloads fit the Swift type. The bug lives in the wire contract: absence and an explicit clear operation were collapsed into one value.
A strong Swift Codable interview answer defines what missing keys, nulls, invalid values, and future fields mean before writing the decoding initializer. Codable can provide structural conversion; it cannot choose an application’s compatibility policy or update semantics.
This guide distinguishes official API facts, local Swift observations, and original engineering exercises. The examples are independent practice, not employer question leaks or evidence of interview frequency. Local fixtures use Apple Swift 6.3.3 with Foundation on arm64 macOS. The historical SE-0166 proposal explains the original design, while current documentation and source describe the API boundary.
What does Codable synthesize, and what remains your decision?
Official fact: Codable combines Encodable and Decodable. Swift can synthesize their implementations for eligible types; a custom init(from:) or encode(to:) supplies application-specific conversion. Apple also documents CodingKeys as the selected wire-property list and its raw strings as external key names. Apple’s custom-type guide, implemented SE-0166 proposal.
That automation is useful for a stable object with matching fields. It does not establish whether an absent display name means unknown, unchanged, or invalid. It also does not validate a business invariant such as a nonnegative retry limit or an identifier belonging to the requested account.
Start an exercise by identifying whether the type represents a complete response, a persisted snapshot, a create request, or a partial update. The same property name can have different requirements in those four contexts. A complete response may require an identifier; a PATCH object should usually avoid pretending it contains an entire replacement entity.
Declare only the direction you need. A response DTO can be Decodable; an outbound command can be Encodable. Adding both directions may suggest a round-trip guarantee that the implementation does not actually provide. Explain which fields must survive conversion and which are intentionally outside the model.
Predict missing, null, and wrong-type outcomes
Compare two small types:
struct RequiredName: Codable {
let displayName: String
}
struct OptionalName: Codable {
let displayName: String?
}
Before running code, predict the outcome for each payload. The following matrix records local observations from the supplied Swift fixture:
| JSON payload | Required String | Optional String? |
|---|---|---|
{} | keyNotFound | Successful decode; nil |
{"displayName": null} | valueNotFound | Successful decode; nil |
{"displayName": 17} | typeMismatch | typeMismatch |
{"displayName": "Ada"} | Successful decode; "Ada" | Successful decode; "Ada" |
Official API boundary: keyed decodeIfPresent returns nil for an absent key or null, but can still throw when a present value cannot be converted to the requested type. contains(_:) lets a custom decoder distinguish presence. Current Swift Codable source.
The useful follow-up is why optionality does not excuse the number 17. Optionality permits no value; it does not turn a string field into an arbitrary JSON value. Likewise, try? around a throwing decode converts failures into nil and can erase a schema violation. Use it only when discarding those failures is an explicit policy, not as a general way to make decoding resilient.
When diagnosing a failure, retain its error category and coding path. For a missing key, the missing key is supplied separately from the container path; an array element’s index helps locate a nested failure. Avoid logging a whole response when its contents are unnecessary to reproduce the shape error.
Does a stored default make a field optional on the wire?
Consider this original prediction exercise:
struct Settings: Decodable {
var retries: Int = 3
}
The local fixture decodes {} as keyNotFound, and {"retries": 9} produces nine. The initializer’s stored value does not define a missing-key fallback for this synthesized mutable property.
A custom decoder can establish that fallback:
struct Settings: Decodable {
let retries: Int
enum CodingKeys: String, CodingKey { case retries }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
retries = try c.decodeIfPresent(Int.self, forKey: .retries) ?? 3
}
}
This version deliberately treats both absence and null as three. The fixture verifies both outcomes and verifies that "three" still throws typeMismatch. If null must be rejected, check presence and then use non-optional decode; do not implement a broader fallback than the contract permits.
There is another, different default use. Official fact: Apple’s guide requires a default for a property omitted from CodingKeys when synthesizing decoding for that type. That property is excluded from the selected wire representation; it is not an optional server field with a decoded override. Apple’s custom-type guide.
Engineering inference: defaulting an optional cosmetic label may be harmless, while defaulting an account ID or authorization field can conceal an incompatible response. Evaluate the meaning of the value rather than applying one generic default wrapper to every field. If retries must also be nonnegative, perform that semantic validation after obtaining the integer and report a deliberate validation error.
Preserve three states for a PATCH request
Here is an original application contract for displayName: absent means leave unchanged, null means clear the name, and a string means replace it. These are this exercise’s rules, not a universal statement about every API using PATCH.
A single String? loses one of those distinctions. An explicit field state preserves it:
enum Field<Value> {
case absent
case null
case value(Value)
}
struct ProfilePatch: Decodable {
let displayName: Field<String>
enum CodingKeys: String, CodingKey { case displayName }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if !c.contains(.displayName) {
displayName = .absent
} else if try c.decodeNil(forKey: .displayName) {
displayName = .null
} else {
displayName = .value(
try c.decode(String.self, forKey: .displayName)
)
}
}
}
The order matters. Establish that the key exists before testing null. Then decode the required string for the value branch so invalid types remain errors. Official API fact: decodeNil(forKey:) tests a present value for null and can throw for a missing key. Current Swift Codable source.
The runnable fixture also supplies a custom encoder. It omits the key for .absent, uses encodeNil for .null, and encodes the string for .value. The three round-trip results are {}, {"displayName":null}, and {"displayName":"Ada"}. A number remains a decoding error.
Apply the resulting command to the existing entity only after validating the update and its authorization. This separates representation from mutation. An absent field should not accidentally overwrite state merely because the DTO’s initializer filled in a convenient default.
How should you migrate a renamed field?
The server is moving from display_name to displayName. During a transition, both may arrive. “Try the new field, then try the old one” is incomplete: what happens when the new field is null or malformed?
Define a precedence policy first. In this original migration exercise, presence of the new key wins. An explicit new null remains nil, a valid new string is used, and an invalid new type fails. The legacy key is considered only when the new key is absent.
| Payload fragment | Expected result under this policy |
|---|---|
Only legacy "display_name": "Old" | "Old" |
New "New" and legacy "Old" | "New" |
New null and legacy "Old" | nil; no legacy resurrection |
New number and legacy "Old" | typeMismatch; no hidden fallback |
The implementation is short:
if c.contains(.displayName) {
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
} else {
displayName = try c.decodeIfPresent(String.self, forKey: .oldName)
}
Its coding keys map .oldName to "display_name"; the exercise uses the default key-decoding strategy. All four cases were checked locally. Encoding emits only the new key, so this is a migration reader with a current-format writer, not preservation of the old spelling.
Engineering inference: accepting both names can help a staged rollout, but it needs an end condition. Track which input version still arrives, choose whether conflicting non-null values should fail instead of using precedence, and remove compatibility behavior only when old consumers or stored snapshots have been addressed. If a global key strategy is also involved, include that decoder configuration in the fixture rather than assuming it cannot affect key lookup.
What survives unknown fields and future enum values?
The local fixture decodes {"displayName":"Ada","future":true} into RequiredName successfully. Re-encoding emits only the modeled name. The extra field was tolerated on input, but it was not retained for output. This observation concerns the ordinary synthesized model with Foundation’s JSON decoder, not every custom decoder.
That distinction matters for a client displaying a response versus a proxy rewriting documents. A display client may intentionally ignore future fields. A proxy or persisted migration may need a separate representation that preserves fields it does not understand. Successful decoding alone does not prove lossless forwarding.
A future enum case presents another boundary. In the fixture, a raw-string enum containing only active and paused rejects "suspended" with dataCorrupted. A custom single-value decoder stores unrecognized strings as .other(String) and its encoder reproduces that string. The underlying Codable design permits custom conversion; the unknown-case policy is an application choice. SE-0166 design.
Do not equate preserving an unknown status with granting it known behavior. An interface might display “unsupported status” and disable a status-dependent action. An authorization decision should require recognized semantics. Silently mapping every future string to .active would create a very different and potentially incorrect policy.
What should your compatibility test demonstrate?
Test the contract’s boundaries, not just one representative success payload. For this article, the executable fixture verifies required versus optional decoding, mutable stored defaults, explicit fallbacks, PATCH states, rename precedence, extra-key loss, and preservation of an unknown enum string. These are actual local assertions, not a claim that an app’s network integration was exercised.
One additional round-trip observation is instructive: decoding {"displayName":null} into the ordinary optional model and encoding it produces {}. If null versus absence matters, compare those semantic states directly. Byte equality is a separate requirement: whitespace, key order, numeric spelling, and unknown-field preservation are not guaranteed by “it conforms to Codable.”
For a nested response, add the exact failure path to the test. For versioned storage, retain old payload fixtures and define migration behavior. For dates or identifiers, record the encoder/decoder configuration and domain validation rules. Explain which changes the reader accepts, which the writer emits, and which inputs remain intentionally invalid.
Sources and further reading
- Apple: Encoding and Decoding Custom Types — synthesis, coding keys, and manual conversion.
- Swift standard library: Codable.swift — current keyed-container API declarations and default implementations.
- SE-0166: Swift Archival & Serialization — the implemented historical design proposal, not a substitute for every current API detail.