Search code examples
kotlinserializationkotlin-multiplatformkotlinx.serialization

Collection as String kotlinx.serialization.KSerializer deserialize


I'm using kotlinx.serialization in a KMP sdk for response parsing. I've seen that the properties module can be used to easily create the query map from a given class (I'm using interface inheritance to produce cleaner and safer code).

An example of a query class could be:

@Serializable
class MyQuery(
  val latitude: QueryCollection<Float>, // alias to `@Serializable(with = ...) Collection<T>`
  val variables: QueryCollection<Variables>,
)

I need to generate a ?latitude=1.0,2.0,-3.0&variables=hello,stack,overflow string from it.

I got everything, but the deserialize method working. It's not crucial to get it implemented, but I wanted to get a better understanding of the library. Unfortunately, I couldn't get the single element decoding step from String to T working.

Here's the trivial code:

class QueryCollectionSerializer<T>(
    private val dataSerializer: KSerializer<T>
) : KSerializer<Collection<T>> {

    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("QueryCollection<T>", PrimitiveKind.STRING)

    override fun deserialize(decoder: Decoder): Collection<T> =
        TODO("Deserialization not (yet) implemented")

    override fun serialize(encoder: Encoder, value: Collection<T>) =
        encoder.encodeString(value.joinToString(","))

}

typealias QueryCollection<T> = @Serializable(QueryCollectionSerializer::class) Collection<T>

Point 1: Is the (string) primitive kind appropriate for this situation? Should I be using a ListSerializer instead?

Point 2: I tried

  • decoding one string at a time thruogh decoder.decodeString().split(',').map { /* magic String -> T... How? Idk */ }
  • Using decoder.decodeStructure. I was only able to type/semantically correct code, but it wasn't decoding any element, since I didn't know how to tell the decoder that it should get a new element after a comma

Am I missing something?

I suspect I could be forced to write a custom "Query" format by mostly copying the Properties one, but it honestly seems a bit too much for a feature that I barely anyone would be using (again, this is mostly a thing I want to understand, not just "solve for the sake of the sdk")


Solution

  • Implementing deserialize

    I do not believe there is any "legitimate" way to implement deserialize given how serialize has been implemented. The dataSerializer has been completely bypassed due to converting the collection directly into a string. That means nothing actually knows the "structure" of the collection's elements. Even if you can reliably split the string by a delimiter, you have no way of knowing how to convert each individual string to T, let alone knowing what T is in the first place.

    Descriptor

    Point 1: Is the (string) primitive kind appropriate for this situation? Should I be using a ListSerializer instead?

    Since you've implemented serialize to encode a String, using a PrimitiveSerialDescriptor with PrimitiveKind.STRING is correct.


    Custom Serial Formats

    You are effectively trying to force a particular serial format via a KSerializer. But the format is not the serializer's responsibility. In general, the "flow" of the kotlinx.serialization library looks like:

    +---------------+   +--------------------------+   +---------+   +---------------+
    |               |   |                          |   |         |   |               |
    | Kotlin Object |-->|  SerializationStrategy*  |-->| Encoder |-->| Serial Format |
    |               |   |                          |   |         |   |               |
    +---------------+   +--------------------------+   +---------+   +---------------+
    
    +---------------+   +--------------------------+   +---------+   +---------------+
    |               |   |                          |   |         |   |               |
    | Kotlin Object |<--| DeserializationStrategy* |<--| Decoder |<--| Serial Format |
    |               |   |                          |   |         |   |               |
    +---------------+   +--------------------------+   +---------+   +---------------+
         
       * KSerializer extends both SerializationStrategy and DeserializationStrategy
    
    Interface Purpose
    SerializationStrategy Convert an object into primitives to pass to an Encoder.
    Encoder Format primitives into a serial format.
    Decoder Parse a serial format into primitives.
    DeserializationStrategy Convert primitives from a Decoder into an object.
    KSerializer Combination of both the XXXStrategy interfaces. Whenever you are creating a custom serializer, you will probably want to implement this interface (necessary if you want to specify your implementation via annotations).

    Note: A serializer can be composed of other serializers. This allows "nested" complex types to be serialized into primitives (e.g., String, Int, Boolean, etc.), and vice versa, without the "parent" serializer actually knowing how. For example, the dataSerializer in your serializer is what knows how to serialize and deserialize each T.

    As you can see, the Encoder and Decoder are responsible for the serial format. And ideally, a KSerializer should be able to work with any implementation of Encoder and Decoder.

    If you want to implement serialization to a certain serial format, and there is no existing library for that format, then you will have to implement your own Encoder and/or Decoder. The Kotlin Serialization Guide has a section on creating custom serial formats which may help, if you decide the effort is worth it. Though note custom formats are apparently still experimental.