Provide a way to introspect wrapped descriptors
What is your use-case and why do you need this feature?
I support a format where Nothing in its schema is meaningful, e.g. List<Nothing> is encoded differently than an empty List<Int>. So far I have been getting by with descriptor == NothingSerializer().descriptor, but I'm finding issues with this now that I'm supporting new features of the format in my library.
Basically, if NothingSerialDescriptor is decorated at all, e.g.:
NothingSerialDescriptor.nullableSerialDescriptor("renamed", NothingSerialDescriptor)like with serializer delegation- Or any other
SerialDescriptordelegating its interface to it
Specifically though, it's been useful for serializing list element types right away, like when encodeCollection is called, instead of holding off until the first element is serialized.
In addition to that, I have another feature planned with dynamic element names for serial types that would similarly decorate a serial descriptor by adding an annotation, and that would also be affected by this.
My original proposed solution
Describe the solution you'd like
It feels like Nothing is a special enough type that formats that could support it should be able to tell it apart from any other StructureKind.OBJECT (which is how it appears now), and making that transparent through decorated/delegated SerialDescriptors would be beneficial.
I don't know the best way to support this, but I have a couple ideas.
Adding another serial kind for Nothing would work, though it feels too late at this point. If it's not though, that feels like a good option.
Otherwise, a solution that would work for me is adding an (internal) annotation that kxs can use to identify a Nothing serial descriptor, even when wrapping serial descriptors. Something like this is what I have in mind:
internal object NothingSerialDescriptor : SerialDescriptor {
public override val kind: SerialKind = StructureKind.OBJECT
public override val annotations: List<Annotation> = listOf(NothingSerialKind()) // Added line
public override val serialName: String = "kotlin.Nothing"
// ...
}
internal annotation class NothingSerialKind
// In non-internal package:
@ExperimentalSerializationApi
public val SerialDescriptor.isNothing: Boolean
get() = annotations.any { it is NothingSerialKind }
This way the Nothing-ness is passed down when delegating (assuming the outer descriptor doesn't remove annotations). And I don't think this should be a problem with annotations being experimental since the implementation for isNothing implemented can be changed if needed.
I am curious if this is a proper design concern, or if maybe I'm just approaching this the wrong way. Recommendations welcome, and I'm up for posting a PR if this is a good solution :)
Are you sure that you want to treat List<Nothing> and List<Nothing?> the same way? To me, the first one is always empty and the second can contain any amount of nulls.
Also, if someone wraps Nothing serial descriptor, they probably do not want it to be treated as a regular Nothing. So, I'm not sure that NothingDescriptor is special enough to have its own kind
@sandwwraith Sorry for the late reply! I'm only noticing the response now after running into this again.
I think what I want is a way to see the inner descriptors when they're wrapped. Be it a property to get the inner descriptor that's wrapped, or a property that references the original/innermost descriptor, having some way to unwrap descriptors would solve the issue I'm facing more generally. Functions like Zod's unwrap() for its library's schemas come to mind.
(And this is all assuming I'm not abusing SerialDescriptors, using them beyond what they were meant for as schemas. If I am, do set me straight! :grin:)
Are you sure that you want to treat
List<Nothing>andList<Nothing?>the same way? To me, the first one is always empty and the second can contain any amount ofnulls.
This wasn't the clearest example on my part, but I do agree. It's not that I want to treat these the same way, but more that I want Nothing and e.g. Unit to be treated differently. Wrapping it obscures the original Nothing descriptor, making it tricky to differentiate it from any other StructureKind.OBJECT.
Also, if someone wraps the
Nothingserial descriptor, they probably do not want it to be treated as a regularNothing.
I somewhat agree here too, though I have an example in mind with encoding literal null values. My binary has null as its own primitive type, so I'm wanting a way to model that in the descriptor because I need to encode types ahead of time before the payload.
Since Kotlin's null literals have the type Nothing?, leaning into serialDescriptor<Nothing?>() feels like a natural way to map nulls into my format. (And I was thinking of doing that by introspecting for the Nothing descriptor with isNullable). I could check if a descriptor equals the Nothing? descriptor directly, but that's similarly brittle with wrapping, and with null there are some use cases that are causing issues.
One example that I'm blocked on, I'd like to add an NbtNull type as part of my format's in-memory type hierarchy (exactly like JsonNull), and since it's the same serial form as serialDescriptor<Nothing?>(), delegating to it would be a consistent way to implement the serializer.
class NbtNullSerializer : KSerializer<NbtNull> {
override val descriptor: SerialDescriptor =
SerialDescriptor("com.benwoodworth.knbt.NbtNull", serialDescriptor<Nothing?>())
// ...delegate to the `null` serializer, or maybe serialize with `encode`/`decodeNull()`
}
But, there's the problem of introspecting the original type. My format can't see the Nothing? descriptor, so I don't get it supported for free along with the other Kotlin builtin usages. And even if I add support for this too, there's still the same issue for serializers written outside the library (which is something I'm being conscientious of, since e.g. I have a dev writing their own mutable version of this in-memory class hierarchy)
There are other types in my format that don't fit nicely into the existing SerialKinds, but since they don't have an analog in the Kotlin stdlib, I don't lean into the builtin serializers like I do with Nothing. With them, it's enough for me to mark them with an annotation, because they're still present when wrapped, whether by renaming or making them nullable.
And even beyond my use case, I think there could be a benefit to adding some sort of unwrapping API to SerialDescriptor. With XmlUtil for example, it unwraps XmlSerialDescriptors by exploiting getElementDescriptor(), passing it a negative value to introspect the original descriptor (here).
These are both decent solutions, but they're only doable with types that the library controls.
And even beyond my use case, I think there could be a benefit to adding some sort of unwrapping API to
SerialDescriptor. With XmlUtil for example, it unwrapsXmlSerialDescriptorsby exploitinggetElementDescriptor(), passing it a negative value to introspect the original descriptor (here).
To add a bit of context. The problem here is that xmlutil has an XmlSerializer type that provides a user extensible way to special case (de)serialization for format aware types. It extends KSerializer (in that for non-XML formats it behaves as normal), but for XML it has its own implementations for encoding/decoding. This also includes the requirement to be able to have a different descriptor for both cases. This descriptor needs to be available for wrapped serializers (like nullable) and a different attribute is thus not able to do this.
As it currently is not possible to unwrap the descriptor, I chose the solution of using the fact that getElementDescriptor is delegated, together with a marker annotation to allow exposing this underlying detail. Obviously this requires the consumer to "recognize" the use (hence the marker), but for this extensibility interface that has as express purpose to make format integration work without hard-coding other special types this is not an issue.
However I'd rather not depend on the wrappers delegating without checking and therefor effectively allowing a broadened contract that allows this by accepting a negative index.
I just came across the SerialDescriptor.nonNullOriginal
extension property after poking through the kxs SerialDescriptor sources. I think this is another case that could be solved more generally by exposing the original descriptor in SerialDescriptors.
interface SerialDescriptor {
val original: SerialDescriptor get() = this
// ...
}
I'd be open to submitting a PR if this is a sensible solution. With this, classes implementing SerialDescriptor automatically return themselves as the original, wrapper classes delegating to another descriptor will return that delegate's original.
It would solve this issue for me, I think this could be an improvement on the XmlUtil implementation, and it might make having nonNullOriginal unnecessary.
And @pdvrieze I appreciate the context! It's a clever way to solve the problem, and caught my intrigue while writing an XML serializer. I also found that you left a similar comment in #2631 while doing my Git archeology learning the history behind nonNullOriginal :)