DEV Community

Martynas Petuška
Martynas Petuška

Posted on

JS in Kotlin/JS

Kotlin/JS brings the full awesomness of Kotlin language to the JS ecosystem, providing great standard library, typesafety and lots of modern features not found in vanilla JS.

However one of the biggest strengths of the JS ecosystem is its massive collection of libraries ready for you to use. Kotlin/JS has full interop with JS code, however, just like TS, it demands external declarations to describe JS API surface. There are ways to shut Kotlin compiler up and proceed in a type-unsafe way (ehem, dynamic type), however that beats the whole point of Kotlin as a typesafe language.

Enter this article! Here we'll cover how Kotlin external declarations map to JS imports and how to write your own from scratch. Hopefully you'll learn some tips and tricks along the way.

Basics

JS Module Mapping

To make your Kotlin code play nice with JS code, Kotlin stdlib provides few compiler-targeted annotations usable in tandem with external keyword. Note that external keyword is only required at the top-level declarations and nested declarations are implied to be external.
Consider the following example:

@JsModule("module-name")               // 1
@JsNonModule                           // 2
external val myExternalModule: dynamic // 3
Enter fullscreen mode Exit fullscreen mode
  1. Tells the compiler that this declaration maps to JS module module-name
  2. Tells the compiler that this declaration can also work with UMD resolver. Not needed when using CommonJS.
  3. Declares an external value with dynamic type. This is a reeference to external JS code we can now use from our Kotlin code! dynamic type is an escape hatch, basically telling the compiler that the shape of this value can be whatever (just like in vanilla JS). We'll look into how to make that type-safe later on.

Entity Mapping

So far we've only seen a top-level value marked as external, however it does not stop there. Kotlin/JS supports object, class, interface, fun and even nested declarations for external scope modelling. Here's the recommended mapping between JS and Kotlin entities to use when writing your own declarations:

  • [JS] fields and properties (declared with get and set keywords -> [Kotlin] val or mutable var
  • [JS] functions and lambdas -> [Kotlin] fun member functions or lambda val
  • [JS] class -> [Kotlin] class
  • [JS] anonymous object shapes ({}) -> [Kotlin] interface

With the above suggestion in mind, here's how all these entities in JS translate to Kotlin:

class MyJSClass {
  myField
  constructor(initField = "69") {
    this.myField = initField
  }
  function myMethod(arg1 = 420) {
    return arg1 + 1
  }
  get myProperty() {
    return this.myField
  }
  set myProperty(value) {
    this.myField = value
  }
  get myImmutableProperty() {
    return this.myField
  }

  myLambda = () => ({ result: 1, answer: "42" })
}
Enter fullscreen mode Exit fullscreen mode
external class MyJSClass(initField: String = definedExternally) {
  var myField: String
  fun myMethod(arg1: Int = definedExternally): Int
  var myProperty: String
  val myImmutableProperty: String

  interface MyLambdaReturn {
    var result: Int
    var answer: String
  }
  val myLambda: () -> MyLambdaReturn
}
Enter fullscreen mode Exit fullscreen mode

Note the special definedExternally value. It's a neat way to tell the compiler that an argument has a default value in JS without having to hard-code it in the Kotlin declarations as well. It can also be used to declare optional properties on external interfaces that you plan on constructing in Kotlin (to pass as arguments to other external entities). There's a slight limitation to this trick - only nullable types can have default implementations declared.

external interface MyJSType {
  val optionalImmutableValue: String?
    get() = definedExternally
  var optionalMutableValue: String?
    get() = definedExternally
    set(value) = definedExternally
}
val myJsTypeInstance: MyJSType = object: MyJSType {
  // Now we only need to override properties we want to set
  override val optionalImmutableValue: String? = "noice"
}
Enter fullscreen mode Exit fullscreen mode

Declaring NPM packages

Most of the time you'll need to work with NPM packages, which comes with a single entry-point declared in the package.json and re-exports deeply nested moduled from a single module.

To declare such packages in Kotlin, there are two strategies for you to use - object and file.

To showcase both, consider this JS module named js-greeter example and see how it can be declared in Kotlin:

export const value = "69"
export const anonymousObjectValue = {
  name: "John"
}
export class JSClass {
  static function initialise() {}
  memberValue = 420
}
export function defaultHello() {
  return "Default Hi"
}
export const helloLambda = (name = "Joe") => (`Hello ${name}`)
export default defaultHello
Enter fullscreen mode Exit fullscreen mode

NPM Package Object

When declaring an object as a container for an external NPM package, that object takes a role of the entire module. When using this strategy, the file can contain a mix of both, external and regular Kotlin declarations.

@JsModule("js-greeter")
external object JSGreeter {
  val value: String

  object anonymousObjectValue {
    var name: String
  }

  class JSClass {
    companion object {
      fun initialise()
    }
    val memberValue: Number
  }

  fun defaultHello(): String

  fun helloLambda(name: String = definedExternally): String

  @JsName("default") // Overriding JS name mapping to `default` rather than `defaultExportedHello`
  fun defaultExportedHello(): String
}
Enter fullscreen mode Exit fullscreen mode

NPM Package File

When declaring a file as a container for an external NPM package, that file takes a role of the entire module and declarations inside that file match 1:1 to the JS module file. When using this strategy, the file can only contain external declarations and mixing of regular Kotlin and external declarations is not allowed. Finally, since all declarations are no longer nested inside external object and instead are top-level declarations, each of them must be marked as external individually.

@file:JsModule("js-greeter")

external val value: String

external object anonymousObjectValue {
  var name: String
}

external class JSClass {
  companion object {
    fun initialise()
  }
  val memberValue: Number
}

external fun defaultHello(): String

external fun helloLambda(name: String = definedExternally): String

@JsName("default") // Overriding JS name mapping to `default` rather than `defaultExportedHello`
external fun defaultExportedHello(): String
Enter fullscreen mode Exit fullscreen mode

Declaring Global JS API

Sometimes you might need to hook into some JS API that does not come from NPM but is provided by the runtime in the global scope. In such cases all you need is to declare the API shape anywhere in your project without any of the module annotations. Here's an example of how to get access to ES6 dynamic imports (note that the return Promise type comes from WEB API declarations provided in Kotlin standard library)

external fun import(module: String): Promise<dynamic>
Enter fullscreen mode Exit fullscreen mode

Declaring non-JS modules

JS development has evolved past JS-only projects and often uses various webpack loaders to "import" non-JS assets. This is possible in Kotlin/JS as well via the same strategies that we used to import JS modules. It's important to note that just like in JS, appropriate webpack loaders must be configured for such imports to work.

Here are some exotic JS import examples and their equivalents in Kotlin.

import CSS from "my-library/dist/css/index.css"
import SCSS from "my-library/dist/scss/index.scss"
import JsonModule from "my-library/package.json"
Enter fullscreen mode Exit fullscreen mode
@JsModule("my-library/dist/css/index.css")
external val CSS: dynamic

@JsModule("my-library/dist/scss/index.scss")
external val SCSS: dynamic

@JsModule("my-library/package.json")
external val JsonModule: dynamic
Enter fullscreen mode Exit fullscreen mode

Getting Rid of dynamic Type

While dynamic type is very convenient and useful in places where you want to tie-up external API declarations chain, it discards all type-safety that Kotlin provides. In most of the cases you should aim to declare the shape of the type via an external interface instead. While external interfaces can be nested inside your module declarations, it is not mandatory and they can live anywhere in your project because they're discarded during compilation and are not present at runtime.

@JsModule("my-library/package.json")
external val packageJson: dynamic

// === VS ===

external interface PackageJson {
  val name: String
  val private: Boolean
  val bundledDependencies: Array<String>
}

@JsModule("my-library/package.json")
external val typedPackageJson: PackageJson
Enter fullscreen mode Exit fullscreen mode

They can also be used to reuse common traits between external declarations by making other external declarations (such as classes) implement such external interfaces.

Summary

We've seen lots of options available to us when mapping Kotlin code to external JS code in order to maintain type safety and unlock a massive ocean of NPM libraries. Hopefully you found something useful in here.

If I missed anything, let me know in the comments and I'll add it in to make this article as complete as possible.

Happy coding!

Discussion (0)