The null that meant three different things
Not-yet-loaded, absent, and explicitly-cleared share one representation, and the UI has to guess.
TL;DR
avatarUrl: String? is null in three unrelated situations: the profile has not loaded yet, the
user has never set an avatar, and the user deliberately removed theirs. Those need three different
UI treatments, and the type offers one.
So the UI guesses. Ours showed a loading shimmer for users who had no avatar, and a default avatar
for users whose profile was still loading — then a PATCH sending avatarUrl: null turned out to
mean "do not change it" to the server and "clear it" to the client.
Model the states. A sealed type with three cases costs ten lines and removes a class of bug that otherwise reappears in every consumer.
The case this post is about
A profile model with the obvious nullable fields:
data class Profile(
val id: String,
val displayName: String,
val avatarUrl: String?, // null = ?
val bio: String?, // null = ?
val lastSeenAt: Instant?, // null = ?
)
// the UI
if (profile.avatarUrl != null) AsyncImage(profile.avatarUrl)
else DefaultAvatar()
Reads naturally, and every null in that class means something different depending on how the object was obtained.
The symptom
A loading shimmer that never resolved, for users with no avatar:
profile loaded, avatarUrl = null (user never set one)
UI: shows a shimmer, because the ViewModel's `isLoading` had been derived
from `avatarUrl == null` in a previous refactor
A default avatar flashing during load, which was the inverse:
t=0 profile = null entirely → DefaultAvatar() shown
t=240ms profile arrives with a real avatarUrl → image swaps in
result: a visible flash of the wrong avatar on every profile open
lastSeenAt read as "never online":
null because the user has privacy settings hiding it
UI: "Last seen: never" ← wrong, and a privacy-adjacent wrong
And the one that caused data loss. A PATCH to update the bio:
PATCH /profile
{ "bio": "new bio", "avatarUrl": null }
The client serialised the whole object. The server's semantics for a missing-or-null field were
"leave unchanged" — except for avatarUrl, where a null had been implemented as "clear it", because
the clear-avatar feature had been built that way. 1,100 users lost their avatar by editing their
bio.
support tickets: "my profile picture disappeared when I changed my bio" 1,100
Why the obvious fix failed
"Document what each null means." We did, in a KDoc comment per field. The comment cannot be enforced, and three of the five consumers had already made their own assumption. Documentation describes a problem that a type could prevent.
"Use a sentinel value." avatarUrl = "" for "explicitly cleared" and null for "not loaded".
Now there are two magic values and a new question about what a whitespace string means. Sentinels
are nulls with extra steps.
"Add an isLoading boolean to the model." Helps the loading case and not the other two, and it
makes impossible states representable — isLoading = true with a populated avatarUrl is a state
nothing prevents.
"Make the field non-nullable with a default." avatarUrl: String = DEFAULT_URL erases the
distinction entirely, so "never set" and "deliberately cleared" become indistinguishable at the
source rather than at the UI.
The actual mechanism
T? is a type with two inhabitants of the "no value" case collapsed into one: it says a value
may be absent and nothing about why.
The three situations are genuinely different because they have different causes and different consequences:
NotLoaded — a property of the REQUEST (we have not asked yet)
Absent — a property of the DATA (there is nothing to show)
Cleared — a property of a USER ACTION (they removed it)
They differ in what the UI should render, in whether a retry makes sense, and in what a write should do. Representing them identically means every consumer re-derives the distinction from context — and context is exactly what a data class does not carry.
And the write side has its own version of the same problem. In JSON, {"bio": "x"} and
{"bio": "x", "avatarUrl": null} are different documents with different intent — "do not
touch" versus "set to nothing" — and a model with a nullable field cannot express the difference,
because serialising it produces one or the other by accident.

T?answers "is there a value". The consumer needs "why is there no value", and that is a different question with three answers. One representation for three states means the consumer guesses, and five consumers guess differently.
The fix
Model the states on the read side, and model presence explicitly on the write side.
1. Read side — loading is a property of the whole resource, not of each field:
// the request's state wraps the data; it is not mixed into it
sealed interface Loadable<out T> {
data object Loading : Loadable<Nothing>
data class Ready<T>(val value: T) : Loadable<T>
data class Failed(val reason: String) : Loadable<Nothing>
}
// the DATA models only data-level absence, with the reason
data class Profile(
val id: String,
val displayName: String,
val avatar: Avatar,
val bio: String?, // null here genuinely means "not written"
val lastSeen: LastSeen,
)
sealed interface Avatar {
data class Url(val value: String) : Avatar
data object NeverSet : Avatar
data object Removed : Avatar // the user cleared it: may warrant different copy
}
sealed interface LastSeen {
data class At(val instant: Instant) : LastSeen
data object Hidden : LastSeen // privacy setting — NOT "never"
data object Never : LastSeen
}
// the UI is now exhaustive, and the compiler enforces it
when (val p = state) {
Loadable.Loading -> ProfileShimmer()
is Loadable.Failed -> ProfileError(p.reason, onRetry = vm::retry)
is Loadable.Ready -> when (val a = p.value.avatar) {
is Avatar.Url -> AsyncImage(a.value)
Avatar.NeverSet -> InitialsAvatar(p.value.displayName)
Avatar.Removed -> InitialsAvatar(p.value.displayName) // same today; separable
}
}
Three properties: loading can no longer be confused with absence, Hidden cannot render as
"never", and adding a fourth avatar state breaks the when rather than falling into an else.
2. Write side — distinguish omitted from explicitly-null:
// a three-state wrapper for PATCH semantics
sealed interface Patch<out T> {
data object Unchanged : Patch<Nothing> // omit from the JSON entirely
data object Clear : Patch<Nothing> // send explicit null
data class Set<T>(val value: T) : Patch<T>
}
data class ProfilePatch(
val displayName: Patch<String> = Patch.Unchanged,
val bio: Patch<String> = Patch.Unchanged,
val avatarUrl: Patch<String> = Patch.Unchanged,
)
// a serializer that omits Unchanged and emits null for Clear
// editing the bio now sends exactly: {"bio": "new bio"}
val patch = ProfilePatch(bio = Patch.Set("new bio")) // avatarUrl untouched, by default
Patch.Unchanged as the default is the fix for the data loss: a caller that does not mention a
field cannot accidentally clear it. The previous design required every caller to remember to send
the current value back.
3. And the server contract stated explicitly, because this is a two-sided problem:
PATCH /profile
field omitted → unchanged
field null → cleared
field with a value → set
Documented, and enforced by a contract test on both sides.
How we proved it
That the three UI bugs are now unrepresentable, which is a compile-time claim:
// remove the Avatar.Removed branch
e: 'when' expression must be exhaustive. Add 'Removed' branch or 'else'.
// try to derive loading from the avatar
val isLoading = profile.avatar == null
e: operator '==' cannot be applied to 'Avatar' and 'Nothing?'
The second one is the original bug, now rejected by the compiler.
That the data-loss case is fixed, with a contract test on the serialised payload:
@Test
fun editingOneFieldDoesNotTouchOthers() {
val json = Json.encodeToString(ProfilePatch(bio = Patch.Set("new bio")))
assertEquals("""{"bio":"new bio"}""", json) // avatarUrl ABSENT, not null
}
@Test
fun clearingSendsAnExplicitNull() {
val json = Json.encodeToString(ProfilePatch(avatarUrl = Patch.Clear))
assertEquals("""{"avatarUrl":null}""", json)
}
old model: {"bio":"new bio","avatarUrl":null} ← the bug, in one line
new model: {"bio":"new bio"}
And the production outcome:
"profile picture disappeared" tickets
before: 1,100 over 6 weeks
after: 0 over 12 weeks
Plus the avatar-flash and shimmer bugs, measured as user-visible events:
profile opens, 1,000 sampled sessions
wrong-avatar flash on load
before: on 97% of opens (anyone with an avatar)
after: 0
shimmer shown for a user with no avatar
before: indefinitely
after: 0
The migration cost, since this touched five consumers:
files changed: 34
compile errors to resolve: 61 ← each one a place that had been guessing
of those, that were actual bugs: 11
time to migrate: 1.5 days
Sixty-one compile errors, eleven of which were latent bugs in code nobody had reported yet. That ratio is the argument for modelling the states: the compiler enumerated the guesses.
One limit worth stating: Avatar.NeverSet and Avatar.Removed currently render identically, so
splitting them bought nothing today. I kept them separate because the product team wanted
different copy for a removed avatar eventually, and merging two cases later is trivial while
splitting one is not. That is a judgement call, and a reviewer could reasonably have argued for two
states instead of three.
What this generalizes to
null is one representation for "no value" and you often have several reasons. Not-yet-loaded,
genuinely absent, denied, expired, cleared, out-of-range. Any time a consumer has to infer which
from context, the type is under-specified and every consumer will infer differently.
Separate request state from data state. Loading, failed and retrying are properties of the fetch. Absent and cleared are properties of the data. Mixing them into one object makes impossible combinations representable and forces each consumer to decide which fields are trustworthy.
Omitted and null are different in a partial update, and most models cannot express it. This is
one of the most common silent data-loss bugs in REST APIs. A three-state wrapper with
Unchanged as the default makes the safe behaviour the one you get by not thinking about it.
A refactor that produces compile errors is doing you a favour. Sixty-one errors were sixty-one places that had been guessing, eleven of them wrongly. An exhaustive type turns a class of runtime bug into a list the compiler hands you.
Related
- Throwing across the bridge loses your exception type — modelling failures as data
- StateFlow drops what you needed and keeps what you didn't — separating state from events
- Your error message is an API, and changing it breaks someone — contracts consumers depend on
Keep reading
This is part of 100 practices of senior engineers — the habits that separate five years of experience from one year five times. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".