-
Notifications
You must be signed in to change notification settings - Fork 660
Description
There are some pain points I run into with nullability, and I'm curious where the nullable APIs stand and what the thoughts currently are around them since some are still marked as experimental. Nullability is something I brush up against often enough, so I'm also interested in helping round off some rough edges if it means working with nullable types would be more resilient.
With the current design, there are a few things on my mind:
- The purpose of
descriptor.isNullable
, and inconsistencies with how it's being used - Correctness of generated serializer for generic elements
- Defensive
isNullable
checks leaking into the runtime, sometimes justifying impossible casts - Reliability of
encode
/decodeNullableSerializableValue
, and whether it provides any value
- Defensive
I'm going to mainly focus on the first point, though, since it bleeds into the rest.
For SerialDescriptor.isNullable
, the docs aren't very clear about what the contract specifically is. I'd assume isNullable
means either:
- whether the serializer can accept
null
Kotlin values, - whether the serializer can handle
null
in the serialized form, - or both, with the serialized form's nullability being tied to the Kotlin value's nullability
Poking through some of its uses in the library, it looks like it's interpreted inconsistently in some places. For example:
- ProtoBuf's schema generator uses
isNullable
to determine the shape of the serialized form (as optional in the schema, and omitted when encodeNull() is used)- So, this usage assumes either (2.) or (3.) is the case.
- But, JsonNullSerializer (implicitly) has
isNullable = false
. It doesn't acceptnull
Kotlin values, but does encodenull
in the serialized form.- This means the code assumes only (1.) is the case, but that's contradicting ProtoBuf's usage.
Here's an overview of nullability usage I put together to guide my thought process and help bring things into focus:
Usages of isNullable
inside the library
K
: Uses isNullable
to assume that the Kotlin type can be null
S
: Uses isNullable
to assume that the serialized form can be null
- kotlinx-serialization-core:
- kotlinx-serialization-json
- kotlinx-serialization-protobuf
Serializers with isNullable
outside the library
From this GitHub search (228 files), these are all the KSerializer
implementations I found where descriptor.isNullable
is true (or potentially true, e.g. delegating to a constructor parameter).
Here's the complete table, including the serializers that didn't actually have isNullable
: potentially-nullable-serializers-on-github.md
Focusing on the 3rd-party serializers that have isNullable
set, here's a summary of their nullabilities:
Nullable Kotlin Type | Nullable Serialized Form | # Serializers |
---|---|---|
false |
true |
24 |
true |
true |
21 |
true |
false |
4 |
false |
false |
3 |
Going off of these:
- If code uses
isNullable
to assume the Kotlin type nullability, it'll be correct 48% of the time. - If code uses
isNullable
to assume the serial form nullability, it'll be correct 87% of the time.
Or, ignoring serializers where the nullability is symmetric between the Kotlin type and serial form:
- assuming Kotlin type nullability is correct 14% of the time
- assuming Serial form nullability is correct 86% of the time
Based on this, it seems like isNullable
is largely used to represent only the serialized form's nullability, but that there's also some general inconsistency with how it's used. This does also match how isNullable
is used within the library, with most isNullable
checks being used to check whether the serialized form can be null, and only a couple checks being there to check the runtime Kotlin type (in KSerializer<T>.nullable
and Encoder.encodeNullableSerializableValue()
).
This is important to note because of those two runtime checks, since as it stands now, using isNullable
to check the Kotlin type's nullability is worse than a coin flip (though not by much). My impression is that some of this is a symptom of how generated serializers are implemented when nullable generic elements are involved. For example, generated serializers can make illegal calls to KSerializer<T>.nullable
with nullable T
, despite T
being non-nullable (and the isNullable
check then justifying a cast from KSerializer<T>
to KSerializer<T?>
, even though that's impossible since KSerializer
is invariant in T
).
As far as solutions for the issue, I think I'd like to see isNullable
changed to strictly reflect the serialized form's nullability, clarifying the documentation and changing the two places that use as Kotlin type checks. Intuitively I think that would makes sense, with most of the serial descriptor already being used to mirror the serialized form. It also plays more nicely when wrapping nullable serializers, compared to it representing the Kotlin type where you'd need to be mindful about updating the nullability when delegating a non-nullable serializer to one for a nullable Kotlin type. There's also the value of introspecting the serialized form, since otherwise there isn't a way to tell if a serializer can encode null
.
That said, though, I do know it's a complex issue, and I understand why there's the need for Kotlin type nullability checks. The Kotlin types aren't always runtime-available, and there's a need to add nullability support to elements with non-nullable serializers, but we also don't want to intercept null
Kotlin values from serializers that can handle them. That trickiness is why I'm curious about the state and current thoughts around nullability. It's something I'd be interested in contributing to, and I can go into more detail about any of this.
Also, just for some context, before writing up this issue I didn't have a great idea how isNullable
really should be used. Just that I had a mental model which occasionally got subtly challenged, leaving me second-guessing whether I actually had it straight in my head. Seeing everything laid out helped clear a lot of it up for me.