
When I was writing this chapter, I was using Kotlin version 1.8.21. Technically speaking, Kotlin/JS should be stable since 1.8.0, but I would not be surprised if some details of what I am explaining in this chapter (like Gradle task names) change in the next versions.
js configured in the kotlin block. To specify the output format, we should call browser, nodejs or useEsModules in the js block, depending on where we want to run our code. If you want to run your code in browser applications, like in the examples from the introduction to this chapter, use the browser block. If you want to generate a NodeJS module, use nodejs. To generate ES6 modules, use useEsModules. Finally, you can call binaries.executable() in the js block, which explicitly instructs the Kotlin compiler to emit executable .js files. We should also specify jsMain (and jsTest if we want to write JS-specific tests) in the sourceSets block.plugins {
kotlin("multiplatform") version "1.8.21"
}
kotlin {
jvm {}
js(IR) {
browser() // use if you need to run code in a browser
nodejs() // use if you need to run code in a Node.js
useEsModules() // output .mjs ES6 modules
binaries.executable()
}
sourceSets {
val commonMain by getting {
dependencies {
// common dependencies
}
}
val commonTest by getting
val jvmMain by getting
val jvmTest by getting
val jsMain by getting
val jsTest by getting
}
}
TheIRin thejsblock means that the Intermediate Representation compiler will be used, which is a modern standard because of its source map generation and the JS optimizations it introduces; moreover, in the future it might help support Kotlin/JS debugging.
kotlin("js") plugin is deprecated, and we should only use kotlin("multiplatform") to target JavaScript.console.log("Hello") to print a message to the console.fun printHello() { console.log("Hello") }
console is declared as a top-level property of type Console. This type has a log function that accepts any values as arguments.@Suppress("NOT_DOCUMENTED") public external interface Console { public fun dir(o: Any): Unit public fun error(vararg o: Any?): Unit public fun info(vararg o: Any?): Unit public fun log(vararg o: Any?): Unit public fun warn(vararg o: Any?): Unit } public external val console: Console
console property and the Console interface are declared as external, which means they are not implemented in Kotlin, but JavaScript provides them. We can use them in our Kotlin code but not implement them. If there is a JavaScript element we want to use in Kotlin but don’t have a declaration for, we can create it ourselves. For instance, if we want to use the alert function, we can declare it as follows:fun showAlert() { alert("Hello") } @JsName("alert") external fun alert(message: String)
js function, we can also call JavaScript elements from Kotlin/JS without explicit declarations. It executes JavaScript code defined as a string and returns its result. For instance, we can use it to call the prompt function:fun main() { val message = js("prompt('Enter your name')") println(message) }
js function must be known at compile time and must be a raw string without any operations. However, inside it we can use local variables defined in Kotlin. For instance, we can use the user variable in the following code:fun main() { val user = "John" val surname = js("prompt('What is your surname ${user}?')") println(surname) }
js is dynamic, which is a special type for Kotlin/JS that is not checked by the compiler and represents any JavaScript value. This means we can assign it to any variable, call any function on it, and cast it to any type. If a cast is not possible, it will throw an exception at runtime. For instance, we can cast js("1") to Int.fun main() { val o: dynamic = js("{name: 'John', surname: 'Foo'}") println(o.name) // John println(o.surname) // Foo println(o.toLocaleString()) // [object Object] println(o.unknown) // undefined val i: Int = js("1") println(i) // 1 }
json function, which expects a vararg of pairs of String and Any? and returns an object of type Json. To create a nested object, use the json function with arguments representing key-value pairs.import kotlin.js.json fun main() { val o = json( "name" to "John", "age" to 42, ) print(JSON.stringify(o)) // {"name":"John","age":42} }
jsBrowserProductionLibraryDistribution Gradle task, the result of which is a .js file and a .d.ts file within your common module's build/productionLibrary directory. The default name of these files is the name of the module, or it can be specified using the moduleName property in the js block inside the kotlin block.common, you should use the :common:jsBrowserProductionLibraryDistribution task, and the result will be common.js and common.d.ts files inside the common/build/productionLibrary directory.jsBrowserProductionLibraryDistribution task is ankimarkdown.js and ankimarkdown.d.ts files inside /build/productionLibrary directory.package.json file. We can do this by adding a line to the dependencies section that defines the path to the directory containing our package, starting from "file:". Use relative paths. This is what it might look like in our example projects:// A project where common module is called common
"dependencies": {
// ...
"common": "file:../common/build/productionLibrary"
}
// AnkiMarkdown project
"dependencies": {
// ...
"AnkiMarkdown": "file:../build/productionLibrary"
}
productionLibrary folder) and call npm link or yarn link. Then, go to your JavaScript or TypeScript project and call npm link <module-name> or yarn link <module-name>. When you subsequently start your project, you should see changes in the dependency immediately after building a new package using the jsBrowserProductionLibraryDistribution task.dev.petuska.npm.publish[^07_3] plugin for this. You need to configure your npm package inside the npmPublish block, set the name and version, and register the npm registry. This is what it currently looks like in the AnkiMarkdown project (before using this plugin, I recommend checking its documentation and alternatives):npmPublish {
packages {
named("js") {
packageName.set("anki-markdown")
version.set(libVersion)
}
}
registries {
register("npmjs") {
uri.set(uri("https://registry.npmjs.org"))
authToken.set(npmSecret)
}
}
}
npmSecret is a string containing the npm token needed to publish your package. You can get it from your npm account.@JsExport annotation, which can be used on classes, objects, functions, properties, and top-level functions and properties. Elements that are not exported will not appear in the .d.ts file, and their methods and property names will be mangled in the .js file.@JsExport
class A(
val b: Int
) {
fun c() { /*...*/ }
}
@JsExport
fun d() { /*...*/ }
class E(
val h: String
) {
fun g() { /*...*/ }
}
fun i() { /*...*/ }
.d.ts result of the jsBrowserProductionLibraryDistribution task will be:type Nullable<T> = T | null | undefined
export class A {
constructor(b: number);
get b(): number;
c(): void;
}
export function d(): void;
export as namespace AnkiMarkdown;
E or function i. If elements are used in other parts of our code, they will be present in the .js file, but property and function names will be mangled[^07_4], so we cannot use them in JavaScript or TypeScript.@JsExport,but there is a problem with this. We cannot use the Kotlin standard library elements in JavaScript or TypeScript because many of its classes are not exported, including kotlin.collections.List. Instead, we should use arrays. Another problematic type is Long because all other types of numbers are transformed to JavaScript number type, but Long is non-exportable[^07_5].- Mark common module classes as
JsExportand adjust them to the limitations. This, for instance, means we need to use arrays instead of lists, and we cannot use theLongtype. Such changes might not be acceptable when JavaScript is not the primary target of our project. - Create wrappers for all classes we want to use in JavaScript or TypeScript. This option is considered better for projects that are not built primarily for Kotlin/JS. This is what such wrappers could look like:
@JsExport @JsName("SudokuGenerator") class SudokuGeneratorJs { private val sudokuGenerator = SudokuGenerator() fun generate(): SudokuJs { return SudokuJs(sudokuGenerator.generate()) } } @JsExport @JsName("Sudoku") class SudokuJs internal constructor( private val sudoku: Sudoku ) { fun valueAt(position: PositionJs): Int { return sudoku.valueAt(position.toPosition()) } fun possibilitiesAt(position: PositionJs): Array<Int> { return sudoku.possibilitiesAt(position.toPosition()) .toTypedArray() } fun isSolved(): Boolean { return sudoku.isSolved() } } @JsExport @JsName("Position") class PositionJs( val row: Int, val column: Int ) fun PositionJs.toPosition() = Position( row = row, column = column ) fun Position.toPositionJs() = PositionJs( row = row, column = column )
JsNameannotation is used to change the name of an element in JavaScript. We often useJsNamefor wrapper classes to give them the same name as the original class, but without the JS suffix. We also sometimes use it to prevent mangling of method or property names.
type Nullable<T> = T | null | undefined
export class SudokuGenerator {
constructor();
generate(): Sudoku;
}
export class Sudoku {
private constructor();
valueAt(position: Position): number;
possibilitiesAt(position: Position): Array<number>;
isSolved(): boolean;
}
export class Position {
constructor(row: number, column: number);
get row(): number;
get column(): number;
}
export as namespace Sudoku;
Flow and StateFlow, which in MVVM architecture are used to represent state and data streams, are not exported. Consider that you have the following class in your common module:class UserListViewModel( private val userRepository: UserRepository ) : ViewModel() { private val _userList: MutableStateFlow<List<User>> = MutableStateFlow(emptyList()) val userList: StateFlow<List<User>> = _userList private val _error: MutableStateFlow<Throwable?> = MutableSharedFlow() val error: Flow<Throwable?> = _error fun loadUsers() { viewModelScope.launch { userRepository.fetchUsers() .onSuccess { _usersList.value = it } .onFailure { _error.emit(it) } } } }
StateFlow and Flow types, which are not exported, so we need to make a wrapper for them. The Flow type represents an observable source of values. A value that wraps it could provide a startObserving method to observe its events, and a stopObserving method to stop all observers.@JsExport interface FlowObserver<T> { fun stopObserving() fun startObserving( onEach: (T) -> Unit, onError: (Throwable) -> Unit = {}, onComplete: () -> Unit = {}, ) } fun <T> FlowObserver( delegate: Flow<T>, coroutineScope: CoroutineScope ): FlowObserver<T> = FlowObserverImpl(delegate, coroutineScope) class FlowObserverImpl<T>( private val delegate: Flow<T>, private val coroutineScope: CoroutineScope ) : FlowObserver<T> { private var observeJobs: List<Job> = emptyList() override fun startObserving( onEach: (T) -> Unit, onError: (Throwable) -> Unit, onComplete: () -> Unit, ) { observeJobs += delegate .onEach(onEach) .onCompletion { onComplete() } .catch { onError(it) } .launchIn(coroutineScope) } override fun stopObserving() { observeJobs.forEach { it.cancel() } } }
CoroutineScope type, which is not exported. This means the FlowObserver constructor can only be used in Kotlin/JS code.StateFlow type represents an observable source of values that always has a value. A value that wraps it should provide the value property to access the current state, a startObserving method to observe state changes, and a stopObserving method to stop all observers. Since StateFlow never completes, it doesn’t call the onComplete or onError methods.@JsExport interface StateFlowObserver<T> : FlowObserver<T> { val value: T } fun <T> StateFlowObserver( delegate: StateFlow<T>, coroutineScope: CoroutineScope ): StateFlowObserver<T> = StateFlowObserverImpl(delegate, coroutineScope) class StateFlowObserverImpl<T>( private val delegate: StateFlow<T>, private val coroutineScope: CoroutineScope ) : StateFlowObserver<T> { private var jobs = mutableListOf<Job>() override val value: T get() = delegate.value override fun startObserving( onEach: (T) -> Unit, onError: (Throwable) -> Unit = {}, onComplete: () -> Unit = {}, ) { jobs += delegate .onEach(onEach) .launchIn(coroutineScope) } override fun stopObserving() { jobs.forEach { it.cancel() } jobs.clear() } }
UserListViewModel, like List<User> or Throwable?, might not be understood by JavaScript, so we also need to create wrappers for them. For Flow this is a simple task as we can map its values using the map method. For StateFlow, we need to create a wrapper that maps objects.fun <T, R> StateFlowObserver<T>.map( transformation: (T) -> R ): StateFlowObserver<R> = object : StateFlowObserver<R> { override val value: R get() = transformation(this@map.value) override fun startObserving( onEach: (T) -> Unit, onError: (Throwable) -> Unit = {}, onComplete: () -> Unit = {}, ) { this@map.observe { onEach(transformation(it)) } } override fun stopObserving() { this@map.stopObserving() } }
UserListViewModel class that can be used in JavaScript or TypeScript:@JsExport("UserListViewModel") class UserListViewModelJs internal constructor( userRepository: UserRepository ) : ViewModelJs() { val delegate = UserListViewModel(userRepository) val userList: StateFlow<List<User>> = StateFlowObserver( delegate.usersList, viewModelScope ).map { it.map { it.asJsUser() }.toTypedArray() } val error: Flow<Throwable?> = FlowObserver( delegate.error.map { it?.asJsError() }, viewModelScope ) fun loadUsers() { delegate.loadUsers() } }
export function useFlowState<T>( property: FlowObserver<T>, ): T | undefined { const [state, setState] = useState<T>() useEffect(() => { property.startObserving((value: T)=>setState(value)) return () => property.stopObserving() }, [property]) return state } // Usage const SomeView = ({app}: { app: App }) => { const viewModel = useMemo(() => { app.createUserListViewModel() }, []) const userList = useStateFlowState(viewModel.userList) const error = useFlowState(viewModel.error) // ... }
build.gradle.kts, but you need to wrap such dependencies using the npm function.// build.gradle.kts
kotlin {
// ...
sourceSets {
// ...
val jsMain by getting {
dependencies {
implementation(npm("@js-joda/timezone", "2.18.0"))
implementation(npm("@oneidentity/zstd-js", "1.0.3"))
implementation(npm("base-x", "4.0.0"))
}
}
// ...
}
}
@JsModule annotation in front of the object representing the whole dependency. Here is an example of such definitions for the @oneidentity/zstd-js and base-x libraries:@JsModule("@oneidentity/zstd-js") external object zstd { fun ZstdInit(): Promise<ZstdCodec> object ZstdCodec { val ZstdSimple: ZstdSimple val ZstdStream: ZstdStream } class ZstdSimple { fun decompress(input: Uint8Array): Uint8Array } class ZstdStream { fun decompress(input: Uint8Array): Uint8Array } } @JsModule("base-x") external fun base(alphabet: String): BaseConverter external interface BaseConverter { fun encode(data: Uint8Array): String fun decode(data: String): Uint8Array }
There is a library called Dukat[^07_6] that generates Kotlin declarations based on TypeScript definition files.
The Kotlin/JS Gradle plugin includes a dead code elimination tool that reduces the size of the resulting JavaScript code by removing unused properties, functions, and classes, including those from external libraries.
Dispatchers.IO in Kotlin/JS code.// Example type that cannot be expressed in Kotlin
type ProcessResult = number | "success” | Error
[^07_1]: Link to AnkiMarkdown repository: github.com/MarcinMoskala/AnkiMarkdown
[^07_2]: Link to the repository of this project: github.com/MarcinMoskala/sudoku-generator-solver
[^07_3]: Link to this plugin repository: github.com/mpetuska/npm-publish
[^07_4]: By name mangling, I mean that these names are replaced with some random characters in JS.
[^07_5]: There are some discussions about making types like
List, Map, Set and Long exportable to JavaScript. I truly support this with all my heart because interoperating between Kotlin and JavaScript/TypeScript seems to be the biggest pain at the moment.[^07_6]: Dukat can be found at github.com/Kotlin/dukat
[^07_7]: It is possible to start processes in other threads in browsers that use Web Workers, and there is a KEEP (a document evaluated as part of the Kotlin Evolution and Enhancement Process) that proposes introducing support for a
worker block that behaves similarly to the thread block on JVM.