kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

Annotating property type with @Serializable does not override default serialzer

Open lukellmann opened this issue 2 years ago • 5 comments

Describe the bug

The default serializer of a serializable class can be overwritten by annotating the property that should be serialized with SomeOtherSerializer with @Serializable(with = SomeOtherSerializer::class).

Since the Serializable annotation has the targets PROPERTY, CLASS and TYPE, I expected that this should also work when annotating the type of the property instead of the property itself.

However it seems like this is only true for classes that are not serializable by default and always require specifying a serializer.

The reason I want to annotate the type instead of the property is for using typealiases with it. The project that I want to use this for needs to serialize Instants in different formats and to reduce repetition and improve readability I wanted to replace

@Serializable
data class Data(
    @Serializable(with = InstantInEpochSecondsSerializer::class)
    val instant: Instant
)

with

typealias InstantInEpochSeconds = @Serializable(with = InstantInEpochSecondsSerializer::class) Instant

@Serializable
data class Data(val instant: InstantInEpochSeconds)

To Reproduce

The issue can be reproduced by the following code (requiring kotlinx-datetime as a dependency for the Instant class)

// the Instant class is annotated with @Serializable(with = InstantIso8601Serializer::class)

@Serializable
data class InstantDefault(val instant: Instant)

@Serializable
data class InstantProperty(
    @Serializable(with = InstantInEpochSecondsSerializer::class)
    val instant: Instant,
)

@Serializable
data class InstantType(
    val instant: @Serializable(with = InstantInEpochSecondsSerializer::class) Instant,
)


// the Duration class is not annotated with @Serializable

@Serializable
data class DurationProperty(
    @Serializable(with = DurationInSecondsSerializer::class)
    val duration: Duration,
)

@Serializable
data class DurationType(
    val duration: @Serializable(with = DurationInSecondsSerializer::class) Duration,
)


inline fun <reified T> printJson(value: T) = println(Json.encodeToString(value))

fun main() {
    val instant = Instant.fromEpochSeconds(10)
    val duration = 10.seconds

    // expected {"instant":"1970-01-01T00:00:10Z"}, got {"instant":"1970-01-01T00:00:10Z"}
    printJson(InstantDefault(instant))

    // expected {"instant":10}, got {"instant":10}
    printJson(InstantProperty(instant))

    // this is wrong:
    // expected {"instant":10}, got {"instant":"1970-01-01T00:00:10Z"}
    printJson(InstantType(instant))


    // expected {"duration":10}, got {"duration":10}
    printJson(DurationProperty(duration))

    // expected {"duration":10}, got {"duration":10}
    printJson(DurationType(duration))
}
custom serializers
object InstantInEpochSecondsSerializer : KSerializer<Instant> {
    
    override val descriptor = PrimitiveSerialDescriptor("InstantInEpochSeconds", PrimitiveKind.LONG)

    override fun serialize(encoder: Encoder, value: Instant) {
        encoder.encodeLong(value.epochSeconds)
    }

    override fun deserialize(decoder: Decoder): Instant {
        return Instant.fromEpochSeconds(decoder.decodeLong())
    }
}


object DurationInSecondsSerializer : KSerializer<Duration> {
    
    override val descriptor = PrimitiveSerialDescriptor("DurationInSeconds", PrimitiveKind.LONG)

    override fun serialize(encoder: Encoder, value: Duration) {
        encoder.encodeLong(value.inWholeSeconds)
    }

    override fun deserialize(decoder: Decoder): Duration {
        return decoder.decodeLong().toDuration(DurationUnit.SECONDS)
    }
}

Expected behavior

Instead of printing

{"instant":"1970-01-01T00:00:10Z"}
{"instant":10}
{"instant":"1970-01-01T00:00:10Z"}
{"duration":10}
{"duration":10}

it should print

{"instant":"1970-01-01T00:00:10Z"}
{"instant":10}
{"instant":10}
{"duration":10}
{"duration":10}

(it should use InstantInEpochSecondsSerializer instead of InstantIso8601Serializer for the InstantType.instant property)

Environment

  • Kotlin version: reproduced on 1.6.10 and 1.6.20
  • Library version: 1.3.2
  • Kotlin platforms: JVM
  • Gradle version: reproduced on 7.4 and 7.4.2

lukellmann avatar Apr 05 '22 20:04 lukellmann

Target TYPE was added to be able to specify serializers inside other types, e.g. List<@Serializable(with = InstantInEpochSecondsSerializer::class) Instant>

But I agree it should also work in a described way. However, probably not with typealias.

sandwwraith avatar Apr 06 '22 13:04 sandwwraith

Target TYPE was added to be able to specify serializers inside other types, e.g. List<@Serializable(with = InstantInEpochSecondsSerializer::class) Instant>

This indeed works as intended.

But I agree it should also work in a described way. However, probably not with typealias.

Since the typealias approach works for Duration and inside other types like List, I don't see why it wouldn't work everywhere once this bug is fixed.

lukellmann avatar Apr 16 '22 19:04 lukellmann

I further found out that this seems to be only the case for serializable classes that use a custom serializer and not the generated one.

If Instant in the above example is replaced by

@Serializable(with = SomeCustomSerializer::class)
data class SomeClass(val data: Int)

and InstantInEpochSecondsSerializer by OverrideSerializer I can still observe this bug, however if Instant is instead replaced by

@Serializable
data class SomeClass(val data: Int)

I can no longer observe this bug.

custom serializers
object SomeCustomSerializer : KSerializer<SomeClass> {
    
    override val descriptor = PrimitiveSerialDescriptor("SomeClass", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: SomeClass) {
        encoder.encodeString(value.data.toString())
    }

    override fun deserialize(decoder: Decoder): SomeClass {
        return SomeClass(decoder.decodeString().toInt())
    }
}

object OverrideSerializer : KSerializer<SomeClass> {

    override val descriptor = PrimitiveSerialDescriptor("SomeClassAsAlwaysTheSame", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: SomeClass) {
        encoder.encodeString("AlwaysTheSame");
    }

    override fun deserialize(decoder: Decoder): SomeClass {
        decoder.decodeString()
        return SomeClass(1)
    }
}

lukellmann avatar Apr 20 '22 04:04 lukellmann

Do you think I could contribute a fix myself? I haven't worked with kotlin compiler/plugin source before, so where would be the right place to get started?

lukellmann avatar Jul 30 '22 21:07 lukellmann

Plugin sources are located in the Kotlin repository: https://github.com/JetBrains/kotlin/tree/master/plugins/kotlin-serialization/kotlin-serialization-compiler (ignore the README though, it's outdated since all Kotlin idea plugins are now in intellij monorepo).

It may be hard to get a grasp on large compiler API, but of course, nothing is impossible :)

sandwwraith avatar Aug 03 '22 15:08 sandwwraith

It seems like you did some work related to this issue @sandwwraith. Will this be fixed now in some next version?

lukellmann avatar Oct 04 '22 17:10 lukellmann

@Lukellmann Yes, the fix should go to the master branch of Kotlin which is now 1.8.20. I'll see if it's possible to backport fix to 1.8.0

sandwwraith avatar Oct 04 '22 17:10 sandwwraith

That's amazing, thanks :)

lukellmann avatar Oct 04 '22 17:10 lukellmann