3

I want a method on a parent Interface / Abstract Class that makes use of generics methods to pass in the class of the implementing class.

interface Domain {
    fun toJSON(): String { return Json.encodeToString(this) }
}

@Serializable
class User: Domain {
    val a: Int
}

This doesn't work since Json.encodeToString doesn't know the class of 'this'.

@Serializable seems to implement KSerializer so in theory I could require domain to descend from it, but that interface is templated. And marking the implementing class @Serializable doesn't seem to implement KSerializer until compile time so creates errors.

How do I implement this toJSON() method or tell Domain that its implementers must be @Serializable / KSerializer?

I have also tried:

interface Domain<T> {
    fun toJSON(): String { return Json.encodeToString(this) }
}

@Serializable
class User: Domain<User> {
    val a: Int
}

But this results in:

kotlin.IllegalStateException: Only KClass supported as classifier, got T

One additional complication in all this is that I'm attempting to do this in KMM.

aepryus
  • 4,715
  • 5
  • 28
  • 41

3 Answers3

2

It does not work, because encodeToString() resolves the type at compile time, not at runtime (it uses reified type).

Maybe there is a better way, but you can do this by acquiring a serializer manually, resolving the type at runtime:

fun toJSON(): String {
    val serializer = Json.serializersModule.serializer(this::class.createType())
    return Json.encodeToString(serializer, this)
}

Note this will only work on JVM. If you need to do this in KMM then be aware that multiplatform reflection is complicated and/or it needs some time to mature. kotlinx.serialization provides a way to do this, but there are warnings about possible inconsistent behavior between platforms:

@OptIn(InternalSerializationApi::class)
fun toJSON(): String {
    @Suppress("UNCHECKED_CAST")
    val serializer = this::class.serializer() as KSerializer<Any?>
    return Json.encodeToString(serializer, this)
}
broot
  • 21,588
  • 3
  • 30
  • 35
  • This seems like a solution that should work. I left out that I was attempting to do this from within KMM. I believe that from within the 'common' area I do not have access to the `createType` method, unfortunately (unless I'm doing something wrong). – aepryus Oct 12 '21 at 21:27
  • 1
    I updated my answer. – broot Oct 12 '21 at 22:32
2

Json.encodeToString uses a reified generic to access the class of the parameter. It means it uses the declared type of the parameter (known at compile time), which here is Domain.

The simplest approach would be to just use your own generic reified extension function to capture the declared class of the receiver more precisely:

interface Domain

inline fun <reified T : Domain> T.toJSON(): String = Json.encodeToString(this)

@Serializable
data class User(
    val name: String,
    val age: Int,
): Domain

fun main() {
    val user = User("Bob", 35)
    println(user.toJSON())
}

Note, however, that this suffers from the same problem: if you try to use this extension on a variable declared with Domain type, it still won't try to access the runtime type:

val user: Domain = User("Bob", 35)
println(user.toJSON()) // still a problem here

If you want to actually use the dynamic type, you can access the serializer dynamically instead like @broot mentioned in his answer.

Joffrey
  • 32,348
  • 6
  • 68
  • 100
  • For my particular use case this would probably suffice. However, I neglected to mention that I'm attempting to do this from the KMM. For some reason, the toJSON method is not being exposed to iOS. I'll try to track down the reason why. – aepryus Oct 12 '21 at 21:36
2

@Serializable seems to implement KSerializer

That's not true. @Serializable is an annotation that instructs the annotation processor to generate (among other stuff) a serializer() method (for companion object), which returns an instance of KSerializer for this class. It doesn't add new interfaces for the class itself, so you can't tell the type system what it should check.

If a serializer won't be found, you'll get a runtime error:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

class HasNoSerializableAnnotation

fun main() {
    // Compiles fine, but will throw runtime exception
    // kotlinx.serialization.SerializationException: Serializer for class 'HasNoSerializableAnnotation' is not found.
    Json.encodeToString(HasNoSerializableAnnotation()) 
}

That's intentional because it's possible to serialize instances of classes without a plugin-generated serializer (even if you don't pass custom serializer manually into encodeToXXX method as another parameter - via passing serializer (as contextual) in serializersModule).

How do I implement this toJSON() method or tell Domain that its implementers must be @Serializable

There is no way to do this. You can have this API, but it's runtime-errors prone:

interface Domain
inline fun <reified T : Domain> T.toJSON() = Json.encodeToString(this)

How do I implement this toJSON() method or tell Domain that its implementers must be KSerializer

Actually, you don't need whole KSerializer here - just its serialization part:

interface Domain
inline fun <reified T : Domain> T.toJSON(serializer: SerializationStrategy<T>) = 
    Json.encodeToString(serializer,this)
  • This seems to work; it compiles. I neglected to mention that I'm attempting to do this from the KMM. For some reason, the toJSON method is not being exposed to iOS. I'll try to track down the reason why. – aepryus Oct 12 '21 at 21:33
  • 1
    Inline classes are not yet supported in Objective-C (as well as Swift) interop (https://kotlinlang.org/docs/native-objc-interop.html#unsupported) – Михаил Нафталь Oct 12 '21 at 22:59