I moved the iOS login-response parser over to Android. Line for line, same field names, same types. Both simulator and emulator passed. Logging in with a test account worked fine.
Then after release, some users reported a crash right after login, on Android only. Not all of them, just some. iOS was fine with the same accounts.
What do you suspect first? I dug through the network layer, token storage, and ProGuard obfuscation. None of it.
The strange combination
Here's the shape of it:
- iOS and Android parse the same server response the same way.
- iOS: every account fine.
- Android: only some accounts crash. The rest are fine.
"Only some accounts" was the key hint. If the whole code path were wrong, everything would break. A split that shows up only for specific accounts means those accounts' responses contain something different.
I looked at the crash log again.
kotlinx.serialization.MissingFieldException:
Field 'displayName' is required for type 'UserProfile', but it was missingPutting the two parsers side by side
The Swift side looked like this:
let displayName = json["displayName"].stringValue // "" if absentPorting to Kotlin, I "tidied it up" like this:
@Serializable
data class UserProfile(
val id: String,
val displayName: String, // non-null. fewer optionals felt more idiomatic
)The server was the problem. For accounts that had never set a display name, the response omits the displayName key entirely. Not null — the key is simply absent.
- Swift: if the key is missing,
.stringValuereturns"". It moves on quietly. - Kotlin: if a
@Serializablenon-null field has no matching key, decoding throwsMissingFieldException.
The ported code looked cleaner than the original. One fewer optional. And that was exactly where it broke.
What would you do here?
Two branches:
- Defend every field coming off the wire — all nullable, all
?: default. - Handle it once at the parser boundary — a lenient decoder plus per-field defaults.
Option 1 works now and breaks again on the next field. Twenty fields means twenty guards. I went with option 2.
The fix
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false // treat null and "key absent" the same
coerceInputValues = true // a null on a non-null field becomes the default
}
@Serializable
data class UserProfile(
val id: String,
val displayName: String = "", // a default means an absent key won't throw
)The part that matters is = "" on the last line. When a @Serializable field has a default, an absent key produces the default instead of an exception. What Swift's .stringValue was doing implicitly, Kotlin now does explicitly.
Three self-checks
- Do you have a field that is optional on one platform and non-null on the other? Fields you "cleaned up" during a port are the dangerous ones.
- For optional user input in the response (display name, bio, avatar URL), have you confirmed whether an unset account gets
nullor gets the key omitted entirely? They are not the same. - If a parser crash hits some accounts and not all, have you captured one real raw response from an affected account?
The honest part
I was slow on this because I believed "same logic ported means same behavior." The logic was the same. What differed was the default behavior on a missing value. Swift's Codable / SwiftyJSON default to lenient; kotlinx.serialization defaults to strict.
So I made a rule: before removing an optional during a port, be able to explain why it was optional in the original. If you can't, that optional was holding back a bug.
This is the same family as My Stubs Were Too Clean. There, the test input was tamer than real data and hid the problem. Here, the ported type was tamer than the real response and crashed.
Do one thing now: open both platforms' response models side by side and count the fields where only one side has a ?.