Shipping & Infra4 min read

My Login Only Died on One OS. The Parser Only Threw on One Side.

I ported the same logic from Swift to Kotlin line for line, and login crashed on Android only, for some accounts only. The cause wasn't the code. It was how the two languages treat a missing field.

#gotchas#android#ios#reality-check#debugging
Left panel: Swift reads json["displayName"].stringValue and gets an empty string when the key is absent. Right panel: Kotlin decodes a non-null String field and throws MissingFieldException on the same payload.
Same response, same intent. Swift returns an empty string; Kotlin throws.

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 missing

Putting the two parsers side by side

The Swift side looked like this:

let displayName = json["displayName"].stringValue   // "" if absent

Porting 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, .stringValue returns "". It moves on quietly.
  • Kotlin: if a @Serializable non-null field has no matching key, decoding throws MissingFieldException.

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:

  1. Defend every field coming off the wire — all nullable, all ?: default.
  2. 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 null or 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 ?.

YouTube

My Login Only Died on One OS. The Parser Only Threw on One Side.

Related