Search code examples
kotlinretrofitretrofit2

Is it safe to pass a Kotlin inline class to a Retrofit function


If I have the following inline class:

interface StringId {
  val raw: String
}

@JvmInline
value class MyId(override val raw: String) : StringId

is it safe to pass it to a Retrofit function?

interface MyApiEndpoints {
  @GET("/fetch/{id}")
  suspend fun fetch(@Path("id") id: MyId): Response<JsonObject>
}

Solution

  • I would be cautious. But as long as distinguishing the inline class from its field is not important, you will probably be fine.

    Retrofit works by constructing a proxy class at runtime based on method annotations of the interface passed to Retrofit::create. When an interface method is first called, the proxy uses reflection to inspect the method’s signature and annotations, which are then used to construct an implementation. What interests us here is what reflection is going to see when the type signature mentions an inline class.

    Questions of ‘safety’ (of the kind asked here) are really matters of interface contracts: which behaviours are guaranteed to be stable, and which may be changed on an implementation’s whim depending on its version, interactions with other features, discovered optimisation opportunities or the phase of the moon. In this particular case we want to know the JVM ABI of Kotlin inline classes.

    One might hope that Kotlin specification may elaborate on that topic in detail. But alas, the only Kotlin specification there is only defines the abstract constraints of the language, not any ABI details. Below is all the specification (version 1.5-rfc+0.1) has to say about reflection (§16.2):

    Particular platforms may provide more complex facilities for runtime type introspection through the means of reflection — special platform-provided part of the standard library that allows to access more detailed information about types and declarations at runtime. It is, however, platform-specific and one must refer to particular platform documentation for details.

    And this is what it has to say about the representation of inline classes (§4.1.5):

    [A]n value [sic] class is allowed by the implementation to be inlined where applicable, so that its data property is operated on instead. This also means that the property may be boxed back to the value class by using its primary constructor at any time if the compiler decides it is the right thing to do. [emphasis mine]

    But what should be expect in practice? ABIs are rarely broken deliberately, since this tends to be plenty disruptive, so we can probably count on it not changing too much. And the manual, while it’s no specification, still contains some pretty instructive examples in the section on representation of inline classes:

    Since inline classes are compiled to their underlying type, it may lead to various obscure errors, for example unexpected platform signature clashes:

    @JvmInline
    value class UInt(val x: Int)
    
    // Represented as 'public final void compute(int x)' on the JVM
    fun compute(x: Int) { }
    
    // Also represented as 'public final void compute(int x)' on the JVM!
    fun compute(x: UInt) { }
    

    To mitigate such issues, functions using inline classes are mangled by adding some stable hashcode to the function name. Therefore, fun compute(x: UInt) will be represented as public final void compute-<hashcode>(int x), which solves the clash problem.

    So we should expect a method taking an inline class argument to have a type signature with the underlying field substituted, and a mangled name. This should apply to interface methods as well. But I don’t see any clues that Retrofit accounts for any of this, and it couldn’t even if it tried: the mangling cannot be reversed to discover the actual underlying type. To the library, the method is going to look like any other method taking the underlying type, only with a weird name. This means you might run into trouble with something like this:

    @JvmInline
    value class MyId(val raw: String) {
        override fun toString(): String = "where is your god now?"
    }
    
    interface MyApiEndpoints {
        @GET("/fetch/{id}")
        suspend fun fetch(@Path("id") id: MyId): Response<JsonObject>
    }
    

    As the documentation of @Path explains:

    Values are converted to strings using Retrofit.stringConverter(Type, Annotation[]) (or Object.toString(), if no matching string converter is installed) and then URL encoded.

    At the JVM level, fetch will have a type signature taking a String argument, and the Retrofit-generated implementation will treat it as such. As such, it will not invoke your custom implementation of toString. (In Kotlin, this manages to work, because Kotlin knows the type at compile time and dispatches the method statically.)

    But in situations where the distinction between the inline class and its wrapped field is not important, you just might be able to get away with it.