Easy dependency integration in Kotlin/JS using the "Elm ports" technique

TL;DR : Just starting with Kotlin/JS, I found it hard to use large NPM libraries like Firebase. But my past experiences with Elm and its system of ports helped me find a suitable solution. The GitHub repo is here

TL;DR2 :You can skip the introduction and go straight into the implementation here.

TL;DR3 : You can test the demo here


I'm a backend engineer by trade. But those days, pretty much any idea I have to scratch my own itch can be solved using a simple front-end attached to some basic serverless database like Firebase or Supabase (Big Fan here!).

It's totally fine, except that it leaves me very little time to play around with my dear friend Kotlin 😬. For that reason, and because Kotlin Multiplatform hit 1.0 a couple months back I decided to spend the last weeks diving into Kotlin/Js.

I won't spend much time describing it, but in short Kotlin/JS is letting you write Kotlin that gets transpiled into Javascript, and that can run in your frontend (or you backend, for that matters). Now, there are many reasons why people would or would not want to do that. For me, it's mostly about my experience as a developer. I want to be enjoying myself after a long day of work. And boy I've been spoiled by Elm in the past with Developer Experience, so suffice to say that the bar is high 😁.

In this blog, I want to dive into a specific part of my experience : using large NPM libraries in my projects. I'll use Firebase as an example.

The problem

Let's say that I want to use Firebase Auth and Firestore in my project. I'll need access to a couple functions from the library. Namely :

// A function to sign up / login
const userCred = await signInWithPopup(auth, provider);

// Another one to log out
await signOut(auth);

// One function to add document
await addDoc(collection(firestore, `users/${uid}/messages`), {
    content : message
});

// one to get the list of messages
const querySnapshot = await getDocs(collection(firestore, `users/${uid}/messages`));
  querySnapshot.forEach((doc) => {
    console.log(doc.data()
});


// finally also a method to automatically get updates are new messages are created to avoid having to fetch messages ourselves
const q = query(collection(firestore, `users/${uid}/messages`));
onSnapshot(q, (querySnapshot) => {
    const messages = [];
    querySnapshot.forEach((doc) => {
        callback(messages);
    });
});

Now, let's look at the different ways I have to use those from Kotlin/JS.

Using external declarations

External declarations are a way to describe the javascript so that Kotlin can make use of its static typer. The best use case isn't it. You're using Javascript, but typed and in Kotlin. It looks like this :

@JsModule("is-sorted")
@JsNonModule
external fun <T> sorted(a: Array<T>): Boolean

At first sight, that sounds like a great idea. Except it's more complex than it looks like. The signInWithPopup method needs a provider, and the auth API. We need to basically setup the project. In Javascript, it concise and easy :

const firebaseApp = initializeApp(FIREBASE_CONFIG);
const provider = new GoogleAuthProvider();
const auth = getAuth();
const firestore = getFirestore(firebaseApp);

Thing is, those declarations contain a lot of complexity, and they're heavy to describe. Here is what firebaseApp in the console :

a console.log of firebaseApp.

It's not an impossible job, I just think it's too much given that we're literally never gonna use that firebaseApp again except for instantiating what we need.

Using dynamic imports

Another option that the Kotlin/JS documentation offers is using the dynamic keyword. The keyword basically disables the type checker.

You could basically decide to do it like this :

val firebase: dynamic = require("firebase/app").default

val firebaseConfig: Json = json(
    "key" to "value"
    ...
)
val firebaseApp: dynamic = firebase.initializeApp(firebaseConfig)

Well, of course it's possible but we're then literally removing all the reasons for me to use Kotlin in the first place?! I don't get any type checking any more, less smart completion, error handling, . . .

For me, that's just not an option.

Using Dukat

I think the folks building Kotlin/JS are very well aware of that problem, and they know that everyone is not going to start creating adapters for every single NPM package on Earth. That's a massive work in the first place and even more to maintain. Plus, pretty much all of the Javascript ecosystem offers Typescript definitions those days for their libraries, which is pretty much what we want isn't it?

Enters project Dukat, which aims at leveraging those Typescripts definitions and automatically generate Kotlin external declarations from them. A brilliant idea if you ask me. It looks like this :

$ npm install -g dukat
$ mkdir test; cd test 
$ npm install firebase
$ dukat firebase/app/dist/app/index.d.ts

Which generated a bunch of files like this :

a list of files generated by Dukat

A brilliant idea indeed, but still in very early stage :). So far, none of the libraries that I have tried to convert have really worked and I ended up spending time in generated Kotlin files trying to fix issues, including converted es5 itself. Not fun

On top of that, the development of Dukat has currently been paused as the new IR compiler is being stabilised.

Using a library

Of course, there are folks that have tried to solve that issue before and offer solutions. Namely, the firebase-kotlin-sdk mostly solves that issue in my case.

To be honest, it looks great. I've played around with it just a bit and it would have filled my needs and more. It's also active, and has multiplatform support.

I'm not sure why I didn't go that direction. There are two main reasons : I didn't really want to add a library dependency on top of a library already. We'll be running in the browser and want to be light remember? It looked like overkill for 4 functions. On top of that, the library introduces additional concepts and I didn't want to have to dive into additional documentation. Learning Kotlin/JS itself was well enough.

Remember those good old Elm ports?

At that point I had been struggling between options for a few days. As I was having coffee with my old friend Swen he told me "What don't you just do like we were doing with Elm ports?". HA! There was the nice option I was searching for the past few days :).

A step back, Elm intro

For those of you that never heard about Elm understand what I'm talking about, I have to explain a little. Elm is a pure functional language for the front-end. What that means is that if you write something in Elm, you pretty much CANNOT have runtime exceptions. And of course, everything is strongly typed.

(Seriously though, Elm has been a mind blowing experience for me, try it out).

Now, that sounds nice, but it doesn't play nice with Javascript at all. When you write Elm, the runtime makes sure you are safe. But in the Javascript world on the other end, you benefit from all the nice libraries and APIs; even if they can fail.

To solve that problem Elm introduces the concept of ports, which are a way to bridge from the unsafe world of wild Javascript into the nice, clean world of Elm.

Ports basically allow you to avoid a full rewrite by letting you create strong interface that you define as unbreakable, so that you can interact with your Javascript.

And we're back with Kotlin

It might not be very crystal clear just yet, but there is nothing complex I promise. We'll apply the same concept of ports here in our Kotlin app. What we will do is :

  • Define a clear interface of what we need in Javascript using external declarations
  • On the Javascript side, create a thin layer to do the heavy lifting for us
  • On the Kotlin side, enjoy that safe typed layer, free of anything that we do not require.

Let's come back to the Javascript functions we need in Kotlin :

import {FIREBASE_CONFIG} from "./constants";
import { initializeApp } from "firebase/app";
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut } from "firebase/auth";
import { collection, addDoc, getFirestore, getDocs, onSnapshot, query}  from "firebase/firestore";

const firebaseApp = initializeApp(FIREBASE_CONFIG);
const provider = new GoogleAuthProvider();
const auth = getAuth();
const firestore = getFirestore(firebaseApp);

const userCred = await signInWithPopup(auth, provider);
await signOut(auth);

await addDoc(collection(firestore, `users/${uid}/messages`), {
    content : message
});

const querySnapshot = await getDocs(collection(firestore, `users/${uid}/messages`));
  querySnapshot.forEach((doc) => {
    console.log(doc.data()
});

const q = query(collection(firestore, `users/${uid}/messages`));
onSnapshot(q, (querySnapshot) => {
    const messages = [];
    querySnapshot.forEach((doc) => {
        callback(messages);
    });
});

On that side, we create our own API layer, with only what we need. The inputs we'll get, and the output we need in our Kotlin app.

const firebaseApp = initializeApp(FIREBASE_CONFIG);
const provider = new GoogleAuthProvider();
const auth = getAuth();
const firestore = getFirestore(firebaseApp);

export async function logIn(){
    const userCred = await signInWithPopup(auth, provider);

    return {
        accessToken: userCred.user.accessToken,
        email: userCred.user.email,
        uid: userCred.user.uid,
    }
}

export async function logOut(){
    await signOut(auth);
}

export async function saveMessage(uid, message){
    await addDoc(collection(firestore, `users/${uid}/messages`), {
        content : message
    });
}

export async function getMessages(uid){
    let messages = [];

    const querySnapshot = await getDocs(collection(firestore, `users/${uid}/messages`));
    querySnapshot.forEach((doc) => {
        messages.push({
            id: doc.id,
            content: doc.data().content
        })
    });

    return messages;
}

export async function syncMessages(uid, callback){
    const q = query(collection(firestore, `users/${uid}/messages`));
    onSnapshot(q, (querySnapshot) => {
        const messages = [];
        querySnapshot.forEach((doc) => {
            messages.push({
                id: doc.id,
                content: doc.data().content
            })
        });

        callback(messages);
    });
}

And on the Kotlin side, we now can declare our own thin API layer

external interface AppUser{
    val email: String
    val uid: String
    val accessToken: String
}

external interface AppMessage{
    val id: String
    val content: String
}

@JsModule("@jlengrand/firebase-ports")
@JsNonModule
external object FirebasePorts{
    fun logIn() : Promise<AppUser>
    fun logOut()

    fun saveMessage(uid: String, message: String)
    fun getMessages(uid: String) : Promise<Array<AppMessage>>

    fun syncMessages(uid:String, callback: (Array<AppMessage>?) -> Unit)
}

// And we can use those in my app directly like this
Button(attrs = {
    onClick {
        error = null
        FirebasePorts.getMessages(user!!.uid)
            .then { messages = it }
            .catch { error = it.message }
    }
}) {
    Text("Retrieve messages manually!")
}

And that's it really, there is nothing more. By writing 50 lines of Javascript that I can easily test, I lifted the need for the whole interoperability with the Firebase library in my Kotlin app.

Even better, my Kotlin does not even contain any reference to Firebase any more 🀯. I could just as well change the implementation on the Javascript side to using Supabase and it would work just as nicely. Firebase has become an artifact of implementation.

Conclusion

It's been a long intro, so I'll write some words of conclusion πŸ˜ƒ.

Kotlin/JS is still in its infancy, but I like the power of having access to my favorite backend language to write front-end as well. Let's see what the future holds.

Interacting with the Javascript ecosystem is still a tough cookie though. There are many options, all with their own drawbacks. I believe that the "Elm Ports method" has some great stuff going for it though:

  • No compromises on the Kotlin side, with no dynamics
  • No need to dive into the library's interface to create the Kotlin external declarations.
  • A clean interface, that can even be used as a layer of separation of concern
  • No need for an extra library, you are in control of the whole lot

Of course, it will be way less interesting if you want to build multiplatform modules, or if you use the capabilities of the library extensively.

You can find an example implementation using the code described above on that GitHub repository.

I hope you liked the post, let me know what you think, it's the very beginning of my Kotlin/JS journey πŸŽ‰.

Some references :

0
Subscribe to my newsletter

Read articles from julien Lengrand-Lambert directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

julien Lengrand-Lambert
julien Lengrand-Lambert

Lead Developer Advocate @AdyenDevs πŸ‡«πŸ‡·πŸ‡³πŸ‡±πŸ‡ͺπŸ‡Ί ✨@Kotlin @GoogleDevExpert ✨ and 🍊@Gitpod hero🍊, Editor @JavaMagazineNL. Growing my own food. #devrel #climate