DEV Community

mizchi (Kotaro Chikuba)
mizchi (Kotaro Chikuba)

Posted on

Writing JS FFI in MoonBit

I think MoonBit is a wonderful language, but it's still in early stages and lacks libraries. For AltJS and new languages to gain traction, I believe increasing wrapper libraries and type definitions is important.

This article walks through writing FFI for the MoonBit JS backend, with the hope of learning MoonBit and encouraging more library development.

While MoonBit can generate code for native, wasm, and js, I think targeting JS - which has broad application across web, browser, and server-side - is the quickest path to practical use.

Ideally, you'd eventually move toward pure MoonBit implementations that can run cross-platform.

Getting Started with MoonBit JS FFI

To understand what's possible, I recommend reading this article:

https://www.moonbitlang.com/pearls/moonbit-jsffi

Building on that, I'll summarize techniques I learned while writing React bindings for MoonBit.

The interface differs slightly from this article, but it's essentially the content implemented in this package:

https://github.com/mizchi/js.mbt

The specifics in this article may become outdated, but the fundamental concepts should remain applicable.

Differences from Prior JS FFI Implementations

There are actually multiple MoonBit JS FFI implementations:

mizchi/js.mbt was created with the motivation of "wanting a raw JS API wrapper, even at the cost of safety." Since it's FFI anyway, there are inherent limits to safety.

All libraries share the use of --target js and casting with %identity (described later), so they're interconvertible. That said, you can't use them confidently without understanding the mechanisms, so I'll explain how to call JS while covering MoonBit language features.

Build Configuration for MoonBit JS Backend

When targeting only the JS backend, adding this to moon.mod.json helps:

{
  ...
  "preferred-target": "js"
}
Enter fullscreen mode Exit fullscreen mode

This lets you omit --target js for moon build and moon test defaults. (The default is --target wasm-gc.)

Depending on package structure, running moon build --target js generates code in target/js/release/build/*.js.

To generate JS, one of these settings is needed in moon.pkg.json:

{
  "is-main": true
}
Enter fullscreen mode Exit fullscreen mode

Or:

{
  "link": {
    "js": {
      "exports": ["foo"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With is-main, a function is generated with fn main {} as the entry point. With link, functions specified in exports are exported.

For details, see:

https://docs.moonbitlang.com/en/latest/toolchain/moon/package.html

TypeScript .d.ts files are also generated. Primitive values and simple structs can be called directly from TypeScript. (I'll explain the type conversion rules later.)

A minimal structure looks like:

moon.mod.json
moon.pkg.json
lib.mbt
target/js/build/release/lib.js
target/js/build/release/lib.d.ts
target/js/build/release/moonbit.d.ts # moonbit binding
Enter fullscreen mode Exit fullscreen mode

To use this as a JS library, just import from the entry point:

// index.ts
export { foo } from "./target/js/build/release/lib.js"
Enter fullscreen mode Exit fullscreen mode

To publish as a moonbit package, create a user on mooncakes.io, set {"name": "username/pkg", "version": "x.y.z"} in moon.mod.json according to your namespace, and run moon publish.

First Steps with JS FFI

Define functions available only for the JS backend with extern "js". Libraries containing this can only run with --target js.

(There are ways to design multi-backend libraries while using extern, but that's complex and omitted here.)


///|
extern "js" fn console_log(v : String) -> Unit =
  #|(v) => console.log(v)

fn main {
  console_log("hello")
}
Enter fullscreen mode Exit fullscreen mode

This is special syntax, but learn it as an idiom.

#| is a multi-line string literal equivalent to = "(v) => console.log(v)". It's directly embedded in the generated code.

Output code:

const mizchi$js$examples$js$45$output$$console_log = (v) => console.log(v);
(() => {
  mizchi$js$examples$js$45$output$$console_log("hello");
})();
Enter fullscreen mode Exit fullscreen mode

The function name gets a long namespace prefix, but this is simple code that can be minified with terser etc.

You can see MoonBit's String is generated directly as JS String. (I'll explain conversion rules later.)

Initially you'll write code while checking generated values, but with practice you'll be able to visualize the generated code without checking.

Casting with #external type and %identity

#external is a directive representing values outside FFI. It's not required for JS, but for wasm-gc it represents references like externref for that backend. I add it as a habit.

https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format

"%identity" is a special MoonBit builtin representing the value or reference itself.

Using this, we prepare a type for arbitrary JS values and a casting function:


///|
#external
pub type JsValue

///|
pub fn[A, B] unsafe_cast(a : A) -> B = "%identity"

///|
fn main {
  let val : Int = 100
  let jsval : JsValue = unsafe_cast(val)
  let val2 : Int = jsval |> unsafe_cast
  let _ = val == val2
}
Enter fullscreen mode Exit fullscreen mode

The right-hand input and left-hand type declaration determine unsafe_cast's A, B, making it a function that (unsoundly) casts any A to B.

Generated code:

(() => {
  const jsval = 100;
  const val2 = jsval;
  100 === val2;
})();
Enter fullscreen mode Exit fullscreen mode

This is really all of it - nothing omitted. %identity's unsafe_cast is removed from generated code with no side effects on values.

This makes JsValue usable as an abstraction for any value. Since %identity's unsafe_cast isn't statically type-checked, you can write incorrect casts.

This also passes MoonBit type checking:


///|
fn main {
  let val : Int = 100
  let jsval : JsValue = unsafe_cast(val)
  let val2 : String = jsval |> unsafe_cast
  let _ = val.to_string() == val2
}
Enter fullscreen mode Exit fullscreen mode

What type to cast to is the FFI writer's responsibility. It's like TypeScript's null as any as number.

FFI writers need to master %identity.

Implementing Value Comparison

In MoonBit, implementing impl Eq for T with equal(self: T, other: T) -> Bool enables value comparison with ==.

Let's implement reference comparison for JsValue:


///|
#external
pub type JsValue

///|
pub fn[A, B] unsafe_cast(a : A) -> B = "%identity"

///|
pub extern "js" fn new_object() -> JsValue =
  #| () => ({})

///|
extern "js" fn object_is(a : JsValue, b : JsValue) -> Bool =
  #|(a,b) => Object.is(a,b)

///|
pub impl Eq for JsValue with equal(self, other) -> Bool {
  object_is(self, other)
}

fn main {
  let obj1 = new_object()
  let obj2 = new_object()
  let _ = obj1 == obj2 // false
  let _ = obj1 == obj1 // true
}
Enter fullscreen mode Exit fullscreen mode

Generated code:

const mizchi$js$examples$js$45$output$$object_is = (v,k) => Object.is(v, k);
const mizchi$js$examples$js$45$output$$new_object = () => ({});
function moonbitlang$core$builtin$$Eq$equal$0$(self, other) {
  return mizchi$js$examples$js$45$output$$object_is(self, other);
}
(() => {
  const obj1 = mizchi$js$examples$js$45$output$$new_object();
  const obj2 = mizchi$js$examples$js$45$output$$new_object();
  moonbitlang$core$builtin$$Eq$equal$0$(obj1, obj2);
  moonbitlang$core$builtin$$Eq$equal$0$(obj1, obj1);
})();
Enter fullscreen mode Exit fullscreen mode

It compares obj1 and obj2 generated from FFI using Object.is.

Let's add code to generate undefined, which doesn't exist as a primitive in MoonBit:


///|
pub extern "js" fn undefined() -> JsValue =
  #| () => undefined

///|
pub extern "js" fn null_() -> JsValue =
  #| () => null

///|
pub extern "js" fn JsValue::is_undefined(self : Self) -> Bool =
  #| (v) => v === undefined

///|
pub extern "js" fn JsValue::is_null(self : Self) -> Bool =
  #| (v) => v === null

///|
fn main {
  let v = undefined()
  let _ = v.is_undefined() // true
}
Enter fullscreen mode Exit fullscreen mode

FFI cannot be implemented as getters.

Implementing Show's to_string

To pass values to MoonBit's println() or inspect(), you need to implement the Show trait. MoonBit's trait is designed to be similar to Rust's trait.

For values other than undefined and null, we'll pass value.toString():


///|
pub impl Show for JsValue with output(self, logger) {
  logger.write_string(self.to_string())
}

extern "js" fn ffi_to_string(v: JsValue) -> String =
  # (v) => v.toString()

///|
pub impl Show for JsValue with to_string(self) {
  if self.is_undefined() {
    "undefined"
  } else if self.is_null() {
    "null"
  } else {
    ffi_to_string(self)
  }
}
Enter fullscreen mode Exit fullscreen mode

Observing Values MoonBit Generates

Here's the FFI correspondence table from MoonBit to JS:

String =>   string
Bool    => boolean
Int, UInt, Float, Double => number
BigInt  => bigint
Bytes   => Uint8Array
Array[T] => Array<T>
Function => Function
Enter fullscreen mode Exit fullscreen mode

These types need no conversion and can be passed directly to JS.

Let's observe them in practice. println() goes through MoonBit's to_string making it hard to inspect JS values, but passing directly to console.log shows actual values.

Define a function like this to get console.log output via FFI:

extern "js" fn ffi_console_log(v : Array[JsValue]) -> Unit =
  #|(obj) => console.log(...obj)

///|
pub fn[T] log(v : T) -> Unit {
  ffi_console_log([v |> unsafe_cast])
}
Enter fullscreen mode Exit fullscreen mode

Pass any T through unsafe_cast to display directly. This lets you see what instances look like inside MoonBit.


///|
fn main {
  struct Point {
    x : Int
    y : Int
  }
  log("hello")
  log(1)
  log(3.14)
  log(true)
  let b1 : Bytes = b"abcd"
  log(b1)
  log(fn() {  })
  log([1, 2, 3])
  log(Point::{ x: 10, y: 20 })
  let mut maybe_value : Int? = Some(42)
  log(maybe_value)
  maybe_value = None
  log(maybe_value)
}
Enter fullscreen mode Exit fullscreen mode

Output:

hello
1
3.14
true
Uint8Array(4) [ 97, 98, 99, 100 ]
[Function (anonymous)]
[ 1, 2, 3 ]
{ x: 10, y: 20 }
42
undefined
Enter fullscreen mode Exit fullscreen mode

Great that struct is also supported.

This means these primitives can be treated equivalently between JS and MoonBit.

Objects That Don't Convert Directly

Objects other than these - like enum, Map, Result, Json - expose MoonBit's internal representation:

fn main {
  enum Color {
    Red = 0
    Green
    Blue
  }
  enum Color2 {
    RGB(r~ : Int, g~ : Int, b~ : Int)
    HSL(h~ : Int, s~ : Int, l~ : Int)
  }
  let result : Result[Int, String] = Ok(42)
  log(result)
  log(Color::Red)
  log(Color2::RGB(r=255, g=0, b=128))
  let show : &Show = "hello"
  log(show)
  let map = { "one": 1, "two": 2, "three": 3 }
  log(map)
  let json : Json = {
    "name": "Alice",
    "age": 30,
    "isStudent": false,
    "scores": [85, 90, 95],
    "address": { "street": "123 Main St", "city": "Wonderland" },
  }
  log(json)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Result$Ok$0$ { _0: 42 }
0
$36$mizchi$47$js$47$examples$47$js$45$output$46$42$main$46$Color2$RGB {
  _0: 255,
  _1: 0,
  _2: 128
}
{
  self: 'hello',
  method_0: [Function: moonbitlang$core$builtin$$Show$output$1$],
  method_1: [Function: moonbitlang$core$builtin$$Show$to_string$1$]
}
// ... (Map and Json internal structures)
Enter fullscreen mode Exit fullscreen mode

These should not be passed directly via FFI.

  • enum: Only const enums output as constants; otherwise wrapped as MoonBit objects
  • Parameterized enums don't preserve key names
  • Trait objects become {self: value, method_0:..., method_1: ...} format
  • Map type uses MoonBit's internal storage object
  • Json type is MoonBit's internal Json representation

Implementing get, set, and Function Calls on JsValue

You could write extern "js" for each function call, but that means writing lots of string literals per extern. We also want escape hatches when APIs are missing.

Let's define basic operations on JS values: property access (o[k]), property assignment (o[k]=v), and function calls (o[k](...args)):


///|
#alias("_[_]")
pub extern "js" fn JsValue::get(self : Self, key : String) -> JsValue =
  #| (o,k) => o[k]

///|
#alias("_[_]=_")
pub extern "js" fn JsValue::set(
  self : Self,
  key : String,
  value : JsValue,
) -> Unit =
  #| (o,k,v) => o[k] = v

///|
pub extern "js" fn JsValue::call(self : Self, args : Array[JsValue]) -> JsValue =
  #| (v, a) => v(...a)

///|
pub extern "js" fn JsValue::call_method(
  self : Self,
  key : String,
  args : Array[JsValue],
) -> JsValue =
  #| (o, k, a) => o[k](...a)

///|
fn main {
  let p = new_object()
  p["x"] = 10 |> unsafe_cast
  p["y"] = 20 |> unsafe_cast
  log(p) // => { x: 10, y: 20 }
  log(p["y"]) // => 20
  log(p.call_method("hasOwnProperty", ["x" |> unsafe_cast])) // => true
}
Enter fullscreen mode Exit fullscreen mode

Now we can access, assign, and call property methods.

This doesn't verify whether the target is callable. You could define separate JsObject, JsFunction etc. external types for strictness, but for internal library use and as an escape hatch API, I consider this sufficient.

Use these functions to operate on most JS objects and implement library/environment bindings.

Example: Calling Math.sqrt

Reference the Math object from globalThis and call its sqrt function:


///|
pub extern "js" fn global_this() -> JsValue =
  #| () => globalThis

///|
fn main {
  let r = global_this().get("Math").call_method("sqrt", [16 |> unsafe_cast])
  log(r) // 4
}
Enter fullscreen mode Exit fullscreen mode

MoonBit has builtin arithmetic functions, but depending on implementation, going through FFI may result in smaller build sizes.

Example: Calling fs.readFileSync in Node Environment

moon run --target js runs in Node.js, so calling require() gets external modules. (This obviously depends on node_modules state.)


///|
pub extern "js" fn require(name : String) -> JsValue =
  #| (name) => require(name)

///|
fn main {
  // Cast to function
  let readFileSync : (String, String) -> String = require("node:fs").get(
      "readFileSync",
    )
    |> unsafe_cast
  let content = readFileSync("moon.mod.json", "utf-8")
  log(content)
}
Enter fullscreen mode Exit fullscreen mode

This covers basic MoonBit<=>JS bindings.

Implementing JsValue Conversion with Traits

You can use unsafe_cast extensively, but creating a trait for casting to JsValue is convenient:


///|
pub(open) trait ToJs {
  to_js(Self) -> JsValue
}

///|
/// Example implementing to_js for String
pub impl ToJs for String with to_js(self) -> Val {
  self |> unsafe_cast
}

///|
/// Example implementing ToJs for custom types
struct MyStruct {
  value : Int
}

///|
pub impl ToJs for MyStruct with to_js(self) {
  self.value |> unsafe_cast
}

///|
/// Helper function to convert structs implementing ToJs to JsValue
pub fn js(val: &ToJs) -> JsValue {
  val.to_js()
}
// let v = js("hello")
// let v2 = js(MyStruct::{value: 1})
Enter fullscreen mode Exit fullscreen mode

https://github.com/mizchi/js.mbt/blob/main/src/trait.mbt

Using to_js to call the previously implemented call_method makes implementation easier.

Example implementing bindings for querySelector:


///|
#external
pub type Element

///|
pub impl ToJs for Element with to_js(self) -> JsValue {
  self |> unsafe_cast
}

///|
pub fn Element::query_selector(self : Self, selector : String) -> Self? {
  self.to_js().call_method("querySelector", [selector |> unsafe_cast]) |> unsafe_cast
}
Enter fullscreen mode Exit fullscreen mode

It's full of unsafe_cast, but I accept this as necessary.

Early TypeScript was also full of any. What matters is that the final library user sees natural types, and there are escape hatches when APIs are insufficient.

How strictly to cast is up to the implementer's judgment. After-the-fact jsonschema and TypeScript type definitions have similar challenges.

Interim Summary

  • Simple types generate JS with TypeScript type definitions
  • #external type MyType represents values outside FFI
  • Defining primitive FFI operations like get/set/function calls enables arbitrary JS operations through their combination
  • Ultimately cast with %identity (that's all)
  • Once correctly cast, you can operate in MoonBit's clean world

That said, you'll feel language limitations while writing. Here are my current concerns for FFI:

  • Can't use generics on extern "js" fn
  • Can't use type parameters on traits (no Rust impl where equivalent)
  • Can't express variadic arguments
  • Converting from TS requires re-casting with enum since there's no Union type
  • No directive to restrict FFI-safe objects when mapping FFI responses to structs (no #external struct)
  • Reserved words like type, ref often collide with property names, requiring casting workarounds
  • unsafe_cast passes trait objects {self: value, method_0: ...} as-is (can't set trait bounds)
  • FFI expands as function expressions, so top-level imports aren't possible
    • I submitted a request and was told this will be implemented soon

Advanced: Exceptions and Async

Diving into exceptions and async becomes difficult without MoonBit familiarity. The specification isn't stable yet, so proceed with caution.

This corresponds to mizchi/js/async in js.mbt:

https://github.com/mizchi/js.mbt/tree/main/src/async

Exception and Async Specification

First, let's understand MoonBit's async representation:

https://docs.moonbitlang.com/en/latest/language/async-experimental.html


///|
/// `run_async` spawn a new coroutine and execute an async function in it
fn run_async(f : async () -> Unit noraise) -> Unit = "%async.run"

///|
/// `suspend` will suspend the execution of the current coroutine.
/// The suspension will be handled by a callback passed to `suspend`
async fn[T, E : Error] suspend(
  // `f` is a callback for handling suspension
  f : (
    // the first parameter of `f` is used to resume the execution of the coroutine normally
    (T) -> Unit,
    // the second parameter of `f` is used to cancel the execution of the current coroutine
    // by throwing an error at suspension point
    (E) -> Unit,
  ) -> Unit,
) -> T raise E = "%async.suspend"

///|
#external
type JSTimer

///|
extern "js" fn js_set_timeout(f : () -> Unit, duration~ : Int) -> JSTimer =
  #| (f, duration) => setTimeout(f, duration)

///|
async fn sleep(duration : Int) -> Unit raise {
  suspend(fn(resume_ok, _resume_err) {
    js_set_timeout(duration~, fn() { resume_ok(()) }) |> ignore
  })
}

fn main {
  run_async(() => {
    log("Start sleeping...")
    sleep(1000) catch {
      e => log("Sleep error: " + e.to_string())
    }
    log("Awake!")
  })
}
/// Start sleeping...
/// Awake!
Enter fullscreen mode Exit fullscreen mode

It's a format of wrapping and calling %async.suspend and %async.run in specific ways. (Note: the interface changed when exceptions were introduced and things broke, so be careful.)

Think of suspend as similar to JS's new Promise((resolve, reject) => {...}) interface.

Looking at the async-related parts of the output code:

const mizchi$js$examples$js$45$output$$js_set_timeout = (f, duration) => setTimeout(f, duration);

function mizchi$js$examples$js$45$output$$sleep(duration, _cont, _err_cont) {
  mizchi$js$examples$js$45$output$$js_set_timeout(() => {
    _cont(undefined);
  }, duration);
}
// ... (async driver code)
Enter fullscreen mode Exit fullscreen mode

Looking at the sleep function with async attribute, _cont and _err_cont are added beyond the declared arguments. This transforms into continuation-passing style via callbacks.

This means MoonBit's try-catch and async don't correspond to JS's try-catch or Promise.

So what should you do? The following article introduces wrapping with MoonBit callbacks:

https://www.moonbitlang.com/pearls/moonbit-jsffi

Based on that, I'll implement:

extern "js" fn Error_::wrap_ffi(
  op : () -> Value,
  on_ok : (Value) -> Unit,
  on_error : (Value) -> Unit,
) -> Unit =
  #| (op, on_ok, on_error) => { try { on_ok(op()); } catch (e) { on_error(e); } }
Enter fullscreen mode Exit fullscreen mode

Implementing Synchronous Exceptions

Applying the above article to JsValue:


///|
suberror JsError JsValue

///|
pub fn JsValue::call_method_raise(
  self : JsValue,
  key : String,
  args : Array[JsValue],
) -> JsValue raise JsError {
  let mut res : Result[JsValue, JsValue] = Ok(undefined())
  let op = () => self.call_method(key, args)
  ffi_wrap_call(op, fn(v) { res = Ok(v) }, fn(e) { res = Err(e |> unsafe_cast) })
  match res {
    Ok(v) => v
    Err(e) => raise JsError(e)
  }
}

fn main {
  try undefined().call_method_raise("nonExistentMethod", []) |> ignore catch {
    JsError(e) => log("Caught JsError: " + e.to_string())
  }
}
// Caught JsError: TypeError: Cannot read properties of undefined (reading 'nonExistentMethod')
Enter fullscreen mode Exit fullscreen mode

suberror declares an error type. It takes JsValue as an argument, which should usually be a JS Error instance.

In main we intentionally call undefined.nonExistentMethod() to trigger a JS TypeError. The "Caught JsError:" prefix shows it was caught by MoonBit's try-catch.

Using call_method_raise everywhere while propagating raise is tedious, but accepting some panics and using it selectively in contexts expecting errors like JSON.parse works well.

Async with Non-Synchronous Exceptions

You could extend JsValue, but let's add a new type Promise[T].

We'll wrap Promise callbacks using the earlier suspend function. Design it so Promise[T].unwrap() becomes T raise JsError.

(This code is quite complex. Check the final main function to get the gist.)


///|
pub extern "js" fn ffi_promise_then(
  promise : Promise[JsValue],
  on_fulfilled : (JsValue) -> Unit,
  on_rejected? : (JsValue) -> Unit,
) -> Promise[JsValue] =
  #|(p, ok, err) => p.then(ok).catch(err)

///|
/// Promise abstraction
#external
pub type Promise[T]

///|
pub async fn[T] Promise::unwrap(self : Self[T]) -> T raise JsError {
  suspend((resume_ok, resume_err) => ffi_promise_then(
      self |> unsafe_cast,
      v => resume_ok(v |> unsafe_cast) |> ignore,
      on_rejected=e => resume_err(JsError(e |> unsafe_cast)) |> ignore,
    )
    |> ignore)
}

///|
/// Test function: returns doubled value after 300ms
extern "js" fn lazy_double(v : Int) -> Promise[Int] =
  #| (v) => new Promise((resolve) => setTimeout(() => resolve(v*2), 100))

///|
fn main {
  run_async(() => try {
    let v : Int = lazy_double(21).unwrap()
    assert_eq(v, 42)
  } catch {
    JsError(e) => log(e.to_string())
    _ => panic()
  })
}
Enter fullscreen mode Exit fullscreen mode

Referenced https://github.com/moonbit-community/jmop for implementation.

Converting MoonBit Async Functions to Promises

Sometimes you need to pass Promise-wrapped callback functions to JS. For example, React's useActionState has useActionState(reducer: (state: S, a: Action) => Promise<S>, initial: S): [S, (action: A) => void], which requires Promise wrapping rather than passing async functions directly.

For this, prepare utility functions to wrap MoonBit async functions as JS Promise functions.

Since MoonBit lacks variadic arguments, I define promisifyN functions up to 3 arguments:


///|
extern "js" fn ffi_promise_with_resolvers() -> JsValue =
  #| () => Promise.withResolvers()

///|
pub(all) struct Resolvers[T] {
  promise : Promise[T]
  resolve : (T) -> Unit
  reject : (Error) -> Unit
}

///|
pub fn[T] Promise::with_resolvers() -> Resolvers[T] {
  ffi_promise_with_resolvers() |> unsafe_cast
}

///|
pub fn[R] promisify0(f : async () -> R) -> () -> Promise[R] noraise {
  () => {
    let { promise, resolve, reject } = Promise::with_resolvers()
    run_async(() => try f() |> resolve catch {
      e => reject(e)
    })
    promise
  }
}

///|
pub fn[A, R] promisify1(f : async (A) -> R) -> (A) -> Promise[R] noraise {
  fn(a) {
    let { promise, resolve, reject } = Promise::with_resolvers()
    run_async(() => try f(a) |> resolve catch {
      e => reject(e)
    })
    promise
  }
}

// promisify2 and promisify3 follow same pattern...
Enter fullscreen mode Exit fullscreen mode

I wanted to use T raise? for callbacks to infer raise/noraise, but encountered compiler crashes, so I'm using noraise for now. Explicitly try-catch inside functions.

Top comments (0)