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 nor operationBased 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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

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:

Kotlin
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:

JavaScript
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:

Kotlin
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:

Kotlin
1when (animal.__typename) {
2  "Lion" -> animal.apolloUnsafeCast<Lion>()
3  "Cat" -> animal.apolloUnsafeCast<Cat>()
4}

apolloUnsafeCast:

  • Uses a as cast on non-JS targets

  • Uses 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 as String. The Kotlin enum is still generated, so you can use safeValueOf() to get a Kotlin enum from a String

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:

Kotlin
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:

Text
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:

Feedback

Edit on GitHub

Forums