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

Unable to use custom serializer for Javascript primitive types

Open shikharid opened this issue 5 months ago • 5 comments

Describe the bug Have certain data classes which need to be de/serialised and contain JS types (primarily BigInt). Added a custom serializer but serialisation always throws a ClassCastException.

Happy to contribute a fix, just give me some pointers.

To Reproduce

  • Try running following tests written for js bigint
@Serializable
data class ObjWithPrimitiveJsType(@Contextual val value: BigInt)

fun toBigInt(value: Long) = (js("BigInt") as (dynamic) -> BigInt)(value.toString())

class BigIntSerializer : KSerializer<BigInt> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BigInt.js", PrimitiveKind.INT)
    override fun deserialize(decoder: Decoder): BigInt = toBigInt(decoder.decodeLong())

    override fun serialize(encoder: Encoder, value: BigInt) = encoder.encodeLong(value.toString().toLong())
}

class NativeLongTestsJs {

    fun `js primitive serializer bug`() {
        val data = ObjWithPrimitiveJsType(toBigInt(Random.nextLong()))
        val encoded = json.encodeToString(data)
        println(encoded)
    }
}

Expected behavior

  • Value should ser/deser properly, ser throws CCE instead
  • The culprit seems to be an overridden serialize method in the generated code which does a type check like:
  • Generated code:
  serialize_tl1wd4_k$(encoder, value) {
    return encoder.encodeLong_3didw_k$(toLong_0(toString_1(value)));
  }
  serialize_5ase3y_k$(encoder, value) {
    return this.serialize_tl1wd4_k$(encoder, value instanceof BigInt ? value : THROW_CCE());
  }
  • For JS primitive types, instanceof checks fail and one needs to do a "typeof" check instead iirc
  • This means kotlinx serialization can never be used to work with JS primitive types
    • Especially never for js types, as their BigInt doesn't even return a boxed value. It's just a constructor call that returns the primitive bigint value

Workarounds considered

  • Use a wrapper type for all primitive types, kinda dirty but solves the problem. Boilerplate for js devs who consume it.
  • Use "new Number" type constructors for creating primitive values, like:
    val jsNewNumber = js(
      "function(x) { return new Number(x); }"
    ) as (dynamic) -> JsNumber
    
    • This is not a solution as these objects can be passed directly from js apps in my case
    • And there I cannot control how these values are created (via constructor or notation)

Ideal Solution

  • Some way to mark a serializer js primitive so that it either avoids the instanceof checks or uses typeof check

Environment

  • Kotlin version: 2.1.20
  • Library version: 1.8.1
  • Kotlin platforms: JS for above issue (actual code is targets js/android/ios)
  • Gradle version: 8.7
  • IDE version (if bug is related to the IDE) [e.g. IntellijIDEA 2019.1, Android Studio 3.4]
  • Other relevant context [e.g. OS version, JRE version, ... ]

shikharid avatar Jul 17 '25 03:07 shikharid

For anyone that stumbles upon this, this is the dirty hack I did for serializer:

// create a serializer that will accept Any object, but ser/des it expecting BigInt
class JsLongSerializer() : KSerializer<Any> {

    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("bigint.${Platform.Target}", PrimitiveKind.LONG)

    override fun serialize(encoder: Encoder, value: Any) = encoder.encodeLong(value.unsafeCast<BigInt>().toString().toLong())

    override fun deserialize(decoder: Decoder) = BigInt(decoder.decodeLong().toString())
}

// then configure json as so to get around compile-time generic type checks
val JSON = Json {
    serializersModule = SerializersModule {contextual(PlatformLong::class, JsLongSerializer() as KSerializer<PlatformLong>)}
}

The end effect is instanceof type checks aren't added in generated JS code, only a nullability check added in generated code for the CCE case. So life's good with some dirty hacks.

shikharid avatar Jul 17 '25 12:07 shikharid

I guess unsafeCast is the way to go. cc @JSMonk @broadwaylamb

sandwwraith avatar Nov 10 '25 12:11 sandwwraith

@sandwwraith No, unsafeCast doesn't help here at all. @shikharid, have you tried to use Long as BigInt compilation? Could it be a non-dirty hack solution specifically in this case (still the same problem applies to other JS primitives such as Symbol)

JSMonk avatar Nov 13 '25 21:11 JSMonk

yup aware of that but it wasn't released when this was reported

it will take us a bit to get to 2.2.20 due to upstream projects, will adopt this then

shikharid avatar Nov 14 '25 02:11 shikharid

@JSMonk enabling Long as BigInt won't fix the problem will it though, it seems to fix issues one way (export types with Long kotlin->js).

But if pure js code constructs an object with bigint's inside them and provides it to a kmp generated library which wants to serialise it using kotlinx-ser, things will still go through the same default serialisation path and raise CCE.. or i'm understanding it wrong?

shikharid avatar Nov 14 '25 03:11 shikharid