JS Interoperability
Kotlin/JS is a powerful tool that allows you to compile
Kotlin down to Javascript. apollo-runtime
supports Kotlin/JS out of the box with no code changes required.
With that said, the default implementation has some performance limitations. Kotlin/JS adds a significant
amount of overhead to basic Kotlin data structures (notably List
, Set
, and Map
), and so performance sensitive
workloads (like those found in the Kotlin JSON parsing code paths) can be slow.
To work around this, Apollo Kotlin provides two alternative solutions to work with JS faster:
jsExport
can be up to ~100x faster but does not support the Kotlin type system noroperationBased
codegen.DynamicJsJsonReader
can be up to ~25x faster but requires bypassing some parts of ApolloClient for JS.
jsExport
jsExport
uses
the @JsExport annotation so that the
dynamic JS object is callable from Kotlin directly.
JsExport
is currently experimental in Apollo Kotlin. If you have feedback on it, please let us know via GitHub issues or in the Kotlin Slack community.Because it bypasses the Kotlin type system, using jsExport
comes with limitations.
See Limitations for more details.
Usage
To use it, set jsExport
to true
in your Gradle scripts:
1// build.gradle[.kts]
2apollo {
3 service("service") {
4 packageName.set("jsexport")
5 // opt in jsExport
6 jsExport.set(true)
7 // jsExport only works with responseBased codegen
8 codegenModels.set("responseBased")
9 }
10}
Define a simple executeApolloOperation
in your common sources:
1expect suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation(
2 operation: Operation<D>,
3): D?
For non-JS implementations, implement executeApolloOperation
using your favorite HTTP client (see Using the models without apollo-runtime) and parseJsonResponse
:
1// non-js implementation
2actual suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation(
3 operation: Operation<D>,
4): D? {
5
6 val body = buildJsonString {
7 operation.composeJsonRequest(this)
8 }
9 val bytes = yourHttpClient.execute(somePath, body)
10 val response = operation.parseJsonResponse(BufferedSourceJsonReader(Buffer().write(bytes)))
11 return response.data
12}
On JS, you can use fetch
and unsafeCast()
to cast the returned javascript object into the @JsExport
responseBased model:
1// js implementation
2actual suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation(
3 operation: Operation<D>,
4): D? {
5
6 val body = buildJsonString {
7 operation.composeJsonRequest(this)
8 }
9 val response = fetch(somePath, body).await()
10 val dynamicJson = response.json().await().asDynamic()
11
12 /**
13 * Because responseBased codegen maps to the response data and the models have
14 * @JsExport annotations, you can use unsafeCast directly
15 */
16 return dynamicJson["data"].unsafeCast()
17}
For a more complete example see this gist which uses Ktor for non-JS clients.
How it works
Javascript is a dynamic language, which means that if you don't need methods/prototype functionality you can cast an arbitrary JS object to generated code that matches its shape. For example consider this Javascript:
1// Imagine Kotlin generated a class like this:
2class Point {
3 constructor(x, y) {
4 this.x = x;
5 this.y = y;
6 }
7}
8
9// And we had data like this:
10val point = {
11 x: 10
12 y: 10
13}
14
15// This would be perfectly valid code, even though `point` is not actually a `Point`:
16console.log(point.x)
In Kotlin this would look like:
1data class Point(val x: Int, val y: Int)
2
3val point = jso<dynamic> {
4 x = 10
5 y = 10
6}
7
8val typedPoint = point.unsafeCast<Point>()
9
10console.log(typedPoint.x)
But! That code would fail with a RuntimeException
because, by default the Kotlin compiler mangles properties,
which means that the generated code for the Point
data class, ends up looking like this after Kotlin compiles it:
1class Point {
2 constructor(x, y) {
3 // Note how it's x_1 here and not just x
4 this.x_1 = x;
5 this.y_1 = y;
6 }
7}
To work around this, you need to tell the compiler not to mangle property names, which you can do by annotating the
class with @JsExport
. When you set the jsExport
option on your service, you tell Apollo to annotate each generated
class with @JsExport
so that the property names are not mangled, and you can safely cast.
Accessors and Polymorphism
Typically responseBased
codegen would create companion objects with accessors for polymorphic models. For example:
1public interface Animal {
2 public val __typename: String
3
4 public val species: String
5
6 public companion object {
7 public fun Animal.asLion() = this as? Lion
8
9 public fun Animal.asCat() = this as? Cat
10 }
11}
Unfortunately, @JsExport
does not support companion objects nor extension functions (see limitations).
What's more, @JsExport
has no runtime type information from JS, so it's impossible to tell at runtime if a given
instance is a Cat
or a Lion
. To check this, use __typename
and apolloUnsafeCast
:
1when (animal.__typename) {
2 "Lion" -> animal.apolloUnsafeCast<Lion>()
3 "Cat" -> animal.apolloUnsafeCast<Cat>()
4}
apolloUnsafeCast
:
Uses a
as
cast on non-JS targetsUses
unsafeCast()
(doc) on JS. This does no type checking at all. If for some reason, your response doesn't have the expected shape your program will fail.
Limitations
@JsExport
is an experimental feature in Kotlin and Apollo Kotlin and may change in future versions.@JsExport
only makes sense on response based codegen since it requires the Kotlin models to have the same shape as the JSON.On JS, it is not possible to check if a
@JsExport
instance implements a given class. If you need polymorphism, you must check__typename
to determine what interface to use.Extension functions on generated code break when you use this technique since we are casting a raw JS object and not actually instantiating a class.
generateAsInternal = true
does not work with@JsExport
, since the compiler ends up giving the internal modifier precedence, and thus mangling the property names.Custom adapters can only be used when their target types are supported by JS (see the full list of supported types).
Enums do not support
@JsExport
and are generated asString
. The Kotlin enum is still generated, so you can usesafeValueOf()
to get a Kotlin enum from aString
DynamicJsJsonReader
If you prefer to use operationBased
models, and performance is not as critical, you can
use DynamicJsJsonReader
. DynamicJsJsonReader
works with a JavaScript object that is already parsed on the JS side.
In JS reading response byte by byte from a byte array like Okio incurs a lot of overhead because Kotlin
uses Long
indices in its Arrays, and Longs do not have a JS implementation.
By bypassing this reading, DynamicJsJsonReader
allows faster reading of responses while still keeping the full Kotlin type information.
In testing we've seen a ~25x performance boost on JS platforms using this parser vs ~100x with the @JsExport
approach.
To use DynamicJsJsonReader
, your JS implementation above would become:
1// js implementation
2actual suspend fun <D : Operation.Data> JsonHttpClient.executeApolloOperation(
3 operation: Operation<D>,
4 headers: Array<Array<String>> = emptyArray(),
5 method: HttpMethod = HttpMethod.Post
6): ApolloResponse<D> {
7
8 val body = buildJsonString {
9 operation.composeJsonRequest(this)
10 }
11 val response = fetch(somePath, body).await()
12 val dynamicJson = response.json().await().asDynamic()
13 return operation.parseJsonResponse(DynamicJsJsonReader(dynamicJson))
14}
Benchmarks
We have done some benchmarking using a large polymorphic query result from the GitHub API.
The idea was to compare:
JSON.parse
+unsafeCast
(JsExporrt
)JSON.parse
+DynamicJsJSONReader
BufferedSourceJsonReader
(default configuration)
We ran the tests on Kotlin 1.8.21 on Chrome 112 on a 2021 Macbook Pro M1 Max.
These are the results:
1parse with js export 40327.72686447989 ops/sec
2parse with js export 68 runs
3parse with dnymaic reader 9989.38589840788 ops/sec
4parse with dnymaic reader 54 runs
5parse with buffer reader 394.15146896515483 ops/sec
6parse with buffer reader 63 runs
DynamicJsJsonReader
is ~25x faster than the default configuration, and JsExport
is ~4x faster than DynamicJsJsonReader
.
The code used to generate these results can be seen here: