This content originally appeared on DEV Community and was authored by Martynas Petuška
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 strenghts 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
- Tells the compiler that this declaration maps to JS module
module-name
- Tells the compiler that this declaration can also work with UMD resolver. Not needed when using CommonJS.
- Declares an
external
value withdynamic
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
andset
keywords -> [Kotlin]val
or mutablevar
- [JS] functions and lambdas -> [Kotlin]
fun
member functions or lambdaval
- [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" })
}
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
}
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"
}
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
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
}
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
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>
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.csss"
import JsonModule from "my-library/package.json"
@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
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
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!
This content originally appeared on DEV Community and was authored by Martynas Petuška
Martynas Petuška | Sciencx (2021-12-30T18:14:39+00:00) JS in Kotlin/JS. Retrieved from https://www.scien.cx/2021/12/30/js-in-kotlin-js/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.