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!"
}
}
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 inroot/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!"
}
}
The .js
and .d.ts
files would now contain the Greeting reference.
- Generated .js file ```javascript
function Greeting() {
}
Greeting.prototype.greeting = function () {
return 'Hello World!';
};
Greeting.$metadata$ = {
simpleName: 'Greeting',
kind: 'class',
interfaces: []
};
- **Generated .d.ts file**
```typescript
export namespace jabbar.jigariyo.kmplibrary {
class Greeting {
constructor();
greeting(): string;
}
}
Now you can call Greeting
from JavaScript
console.log(new jabbar.jigariyo.kmplibrary.Greeting().greeting())
// Hello World!
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
)
@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")
}
}
}
}
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
- Code compiles with such warnings, but you might have run-time issues
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
toArray
might have an impact on generated code foriOS
.List
becomesNSArray
on iOS side butArray
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
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()
}
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")
}
}
Generated JS code for the above
hello
method will have amangled
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")
}
}
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)
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()
*/
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)
}
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
}
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
}
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
}
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")
}
}
Generated JS code for hello
method looks like,
Hello.prototype.hello_sv8swh_k$ = function () {
console.log('HELLO from HelloInterface');
};
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$();
};
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
}
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.
Top comments (0)