DEV Community

Cover image for @JsExport guide for exposing Kotlin to JS
Jigar Brahmbhatt for Touchlab

Posted on

@JsExport guide for exposing Kotlin to JS

Note that this post focuses on JS output for Kotlin. There is also a Typescript output (.d.ts file) with some unique issues that this post doesn't cover in detail.

In the previous post, we added Kotlin/JS support to an existing KMM library. Now, we would add code that works on the JS side.

Table Of Contents

Usage

It is critical to understand @JsExport annotation and all the issues around it if you expose Kotlin code through Kotlin/JS as an external JS library

With the new IR compiler, Kotlin declarations do not get exposed to JavaScript by default. To make Kotlin declarations visible to JavaScript, they must be annotated with @JsExport.

Note that @JsExport is experimental as of the posted date of this post (with Kotlin 1.6.10)

Let’s start with a very basic example,

// commonMain - Greeting.kt
class Greeting {
    fun greeting(): String {
        return "Hello World!"
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, the generated .js library file would not have any reference to the Greeting class. The reason is that it is missing the @JsExport annotation.

You can generate JS library code via ./gradlew jsBrowserDistribution. You would find the .js, .d.ts and map file in root/build/js/packages/<yourlibname>/kotlin folder.

Now, add the annotation to generate JS code for it,

import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport

@ExperimentalJsExport
@JsExport
class Greeting {
    fun greeting(): String {
        return "Hello World!"
    }
}
Enter fullscreen mode Exit fullscreen mode

The .js and .d.ts files would now contain the Greeting reference.

  • Generated .js file
function Greeting() {
}
Greeting.prototype.greeting = function () {
  return 'Hello World!';
};
Greeting.$metadata$ = {
  simpleName: 'Greeting',
  kind: 'class',
  interfaces: []
};
Enter fullscreen mode Exit fullscreen mode
  • Generated .d.ts file
export namespace jabbar.jigariyo.kmplibrary {
    class Greeting {
        constructor();
        greeting(): string;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you can call Greeting from JavaScript

console.log(new jabbar.jigariyo.kmplibrary.Greeting().greeting())
// Hello World!
Enter fullscreen mode Exit fullscreen mode

Note that you would have to use fully qualified Kotlin names in JavaScript because Kotlin exposes its package structure to JavaScript.

It is important to keep in mind that all public attributes in your exportable object would also need to be exportable.

In the following example, CustomObj would also need to be exportable to export MyDataClass,

@JsExport
data class MyDataClass(
    val strVal: String,
    val customObj: CustomObj // This would need to be exportable
)
Enter fullscreen mode Exit fullscreen mode

@ExperimentalJsExport vs @JsExport

@JsExport is the annotation you need to tell the compiler to generate JavaScript code, and @ExperimentalJsExport is an opt-in marker annotation to use @JsExport as it is experimental to use.

You can get rid of the requirement of adding @ExperimentalJsExport in code by declaring it as OptIn in languageSettings for all source sets in your kotlin block.

kotlin {
    sourceSets {
        all {
            languageSettings.apply {
                optIn("kotlin.js.ExperimentalJsExport")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Limitations

As of Kotlin 1.6.10, there are heavy limitations on what Kotlin types one can export to JavaScript.

You will most likely face one of these limitations if you add JS support in an existing KMP library.

Whenever something is not-exportable, you would get either an error or a warning:

  • Code does not compile with such errors Js exportable error example
  • Code compiles with such warnings, but you might have run-time issues JS exportable warning

Collections

Kotlin's collections APIs are not exportable, so you would have to come up with different strategies to deal with them. Some examples would be:

Map

You would have to remove Map usage from common code that also exports to JS, or you would have to have a different implementation on the mobile and js side. You can use the kotlin.js.Json object on the jsMain side and then map it to the Kotlin map whenever needed.

For JS specific implementation, you may also look into using Record from kotlin-extensions library.

List

You can replace the List usage with an Array to keep the same code for all platforms. It may or may not be a simple replacement.

For example, Array would work if only used in an object for parsing an API response. Note that having an Array in a Data class would require providing your own equals and hashcode implementations.

Note that moving from List to Array might have an impact on generated code for iOS. List becomes NSArray on iOS side but Array becomes a Kotlin object wrapping the array

If you want separate implementation for jsMain, then kotlin-extensions library provides some helpful JS specific classes like Iterator, Set, and ReadOnlyArray

Long

Long is not mapped to anything as there is no equivalent in the JavaScript world. You would see the non-exportable warning if you export Long via Kotlin.

If you ignore the warning, then Long still kinda works. It just takes any value from JS. Kotlin will receive the input as Long if JavaScript code sends a BigInt.

It will not work for Typescript unless you set skipLibCheck = true in the config as type kotlin.Long is not available.

// Kotlin 
@JsExport
class Greeting {
    @Suppress("NON_EXPORTABLE_TYPE")
    fun printLong(value: Long) {
        print(value)
    }
}

// Generated .js
Greeting.prototype.printLong = function (value) {
  print(value);
  };

// Generated .d.ts
printLong(value: kotlin.Long): void;

// Usage from JS
const value = "0b11111111111111111111111111111111111111111111111111111"
Greeting().printLong(BigInt(value)) // This works

Enter fullscreen mode Exit fullscreen mode

You can use @Suppress("NON_EXPORTABLE_TYPE") to suppress the exportable warning

Interface

Kotlin interfaces are not exportable. It gets annoying when a library has an interface-driven design, where it exposes the interface in public API rather than a specific implementation.

Interfaces will be exportable starting upcoming Kotlin 1.6.20! We would have to play around with that to see it working.

There are workarounds to make interfaces work on JavaScript.

The following are some examples for getting around interfaces:

Using Implementation Class

@JsExport
interface HelloInterface {
    fun hello()
}
Enter fullscreen mode Exit fullscreen mode

The above code would show the non-exportable error. You can use the interface indirectly via its implementation class to work around that problem.

@JsExport
object Hello : HelloInterface {
    override fun hello() {
        console.log("HELLO from HelloInterface")
    }
}
Enter fullscreen mode Exit fullscreen mode

Generated JS code for the above hello method will have a mangled name. Read more about it in code-mangling section

interface HelloInterface {
    @JsName("hello")
    fun hello()
}

@JsExport
object Hello : HelloInterface {
    override fun hello() {
        console.log("HELLO from HelloInterface")
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, here are some variations to use HelloInterface,

// Variation (2)
@JsExport
object HelloGet {
    fun getInterface(): HelloInterface {
        return Hello
    }
}

// Variation (3)
@JsExport
class HelloWrapper(@JsName("value") val value: HelloInterface)

// Variation (4)
@JsExport
data class HelloWrapperData(@JsName("value") val value: HelloInterface)

Enter fullscreen mode Exit fullscreen mode

All above variations are usable from the JS side even with a non-exportable warning around interface usage,

/**
 * JS side calling code
 * (1)
 * Hello.hello()
 *
 * (2)
 * HelloGet.getInterface().hello()
 *
 * (3)
 * const wrapperObj = HelloWrapper(Hello)
 * wrapperObj.value.hello()
 *
 * (4)
 * const wrapperDataObj = HelloWrapperData(Hello)
 * wrapperDataObj.value.hello()
 */
Enter fullscreen mode Exit fullscreen mode

Using Expect-Actual Pattern

Another idea for using interfaces is to use the expect-actual pattern to define a Kotlin interface in common and mobile platforms and define an external interface for the JS side. This approach might not scale well but can be very useful for simple cases.

// commonMain
expect interface Api {
    fun getProfile(callback: (Profile) -> Unit)
}

// jsMain
// Here external makes it a normal JS object in generated code
actual external interface Api {
    actual fun getProfile(callback: (Profile) -> Unit)
}

// mobileMain
actual interface Api {
    actual fun getProfile(callback: (Profile) -> Unit)
}
Enter fullscreen mode Exit fullscreen mode

These examples showcase workarounds that might or might not work for a particular project.

Enum

As of Kotlin 1.6.10, enums are not exportable. It can create issues for projects that have a lot of existing enums.

Good news is that its support coming in Kotlin 1.6.20

There is also a trick to export and use enums on JS. It requires defining a JS-specific object with attributes that point to actual enums.

For example, this code won't compile,

@JsExport
enum Gender {
    MALE,
    FEMALE
}
Enter fullscreen mode Exit fullscreen mode

Instead, you can do this indirectly by re-defining them through object fields. It works with a non-exportable warning. Note the warning suppression with annotation.

@Suppress("NON_EXPORTABLE_TYPE")
@ExperimentalJsExport
@JsExport
object GenderType {
    val male = Gender.MALE
    val female = Gender.FEMALE
}
Enter fullscreen mode Exit fullscreen mode

Sealed classes

Sealed classes are exportable, but they’re buggy as of Kotlin 1.6.10

You can export a data or regular class as subclasses inside a Sealed class body, but not an object.

@JsExport
sealed class State {
    object Loading: State() // This won't be visible 
    data class Done(val value: String): State() // This would be visible
}
Enter fullscreen mode Exit fullscreen mode

You can work around this problem by moving the subclasses outside the body of the sealed class, but then you cannot write it like State.Loading. It is more of a readability issue in that case.

Also, sealed classes have known issues with typescript binding as well.

Code mangling

The Kotlin compiler mangles the names of the functions and attributes. It can be frustrating to deal with mangled names.

For example,

@JsExport
object Hello : HelloInterface {
    override fun hello() {
        console.log("HELLO from HelloInterface")
    }
}
Enter fullscreen mode Exit fullscreen mode

Generated JS code for hello method looks like,

Hello.prototype.hello_sv8swh_k$ = function () {
  console.log('HELLO from HelloInterface');
};
Enter fullscreen mode Exit fullscreen mode

We would need to use the @JsName annotation to provide a generated name. If you see numbers in attribute names like _something_0, _value_3 on the JS side, then it is a sign that you need to provide a controlled name via @JsName annotation on the Kotlin side.

After adding @JsName("hello") in the above example, generated code looks like this where there is a new hello method that references hello_sv8swh_k$ internally,

Hello.prototype.hello_sv8swh_k$ = function () {
  console.log('HELLO from HelloInterface');
};
Hello.prototype.hello = function () {
  return this.hello_sv8swh_k$();
};
Enter fullscreen mode Exit fullscreen mode

Note that @JsName is prohibited for overridden members, so you would need to set it to a base class property or method.

Suspended functions

You cannot expose suspended functions to JS. You would need to convert them into JavaScript Promise object.

The easiest way to do that would be to wrap suspend calls inside,

GlobalScope.promise {
  // suspend call
}
Enter fullscreen mode Exit fullscreen mode

This function comes from Promise.kt in the coroutine library. It returns a generic type.


As mentioned earlier, some of these issues would get resolved with Kotlin 1.6.20, so keep that in mind.


In the next post, we will look at different ways to distribute Kotlin/JS library since we've some JS exportable code.

Thanks for reading! Let me know in the comments if you have questions. Also, you can reach out to me at @shaktiman_droid on Twitter, LinkedIn or Kotlin Slack. And if you find all this interesting, maybe you'd like to work with or work at Touchlab.

Discussion (0)