MoonBit JS Binding Guide
Step-by-step workflow for binding JavaScript APIs (browser DOM, Node built-ins, npm packages) to MoonBit through the js backend.
Verified against MoonBit v0.10.12 and
moonbitlang/async0.21.0.
When To Use
Use this skill when:
- Adding
extern "js" fndeclarations for a JS API - Embedding inline JS (
#| (x) => ...) or importing by module+name (= "Math" "max") - Configuring
moon.pkgwithlink.jsexports andformat: esm/cjs/iife - Wrapping JS objects with
#external pub typeand%identitycasts - Bridging Promises with
async fn+%async.suspend+%async.run - Mapping
undefined/nulldistinctly into MoonBitOption - Consuming npm packages via
require(...)or ESM imports - Generating
.d.tsfor TypeScript consumers
Plan Mode Instructions
If you are in plan mode and creating a plan for moonbit-js-binding work:
- In your final plan file, include an explicit instruction at the beginning that tells the executing agent to read the moonbit-js-binding skill before starting implementation.
- The instruction should look like this:
**Before starting implementation:** Use the Skill tool to load the moonbit-js-binding skill, which provides comprehensive guidance on `extern "js"` declarations, JS value wrapping, Promise bridging, and moon.pkg export configuration.
Type Mapping
Map JS types to MoonBit types before writing any declarations.
| JavaScript | MoonBit | Notes |
|---|---|---|
boolean | Bool | Direct |
number (integer) | Int, UInt | JS is IEEE-754; large Int64 will lose precision — use BigInt instead |
number (float) | Float, Double | Direct |
bigint | BigInt | Direct |
string | String | Direct (both UTF-16) |
Uint8Array | Bytes, FixedArray[Byte] | Direct — no copy |
Array<T> | Array[T], FixedArray[T] | Direct |
Function | FuncRef[(...) -> R] or closure | Direct |
any (opaque) | #external pub type JsValue | Wrap in a nominal type |
T | undefined | T? via is_undefined check | See Null/Undefined section |
T | null | Nullable[T] wrapper | null is NOT undefined |
Promise<T> | #external pub type Promise[T] | Bridge with async fn/wait() |
constant enum | number | pub(all) enum E { A; B } |
Warning — Number precision: JavaScript has no integer type. Values crossing the FFI boundary round to IEEE-754 doubles.
Intis fine for\|x\| < 2^53; beyond that useBigInt.UInt64/Int64currently map through JSnumberand lose bits above2^53.
Warning — Trait objects & MoonBit enums: Passing
Map[K, V],Result[_],Json, or non-#externaltrait objects across FFI exposes MoonBit's internal runtime shape. Convert to plain structs,JsValue, or primitives first.
Workflow
Follow these 4 phases in order.
Phase 1: Project Setup
Configure the module and package for JS output.
Module (moon.mod) — set preferred_target so moon check, moon build, moon test default to js. Since v0.10.9 the compiler's own default target is wasm, so without this every command needs --target js:
moonbitname = "user/pkg" version = "0.1.0" source = "src" preferred_target = "js" import { "moonbitlang/async@0.21.0", }
The JSON form
moon.mod.json(with the hyphenatedpreferred-target,sourceanddepskeys) is deprecated and slated for removal.moon fmtmigrates an existing project to the DSL.
Package (src/moon.pkg) — gate .mbt files to the js backend and configure link output.
File format —
moon.pkg(DSL) vsmoon.pkg.json(JSON): MoonBit still reads either filename, but JSON support is deprecated and scheduled for removal. This skill uses the DSL formmoon.pkg(no extension) throughout. Do NOT writemoon.pkg.json; it is a different syntax (pure JSON, nooptions(...)wrapper, no trailing commas). Mixing them mid-project causes "Unable to readmoon.pkg" parse errors. If you cloned an older template using.pkg.json, runmoon fmtto convert it.
targets:— only needed for backend-specific files: Thetargets:block gates individual.mbtfiles to a subset of backends. Files that containextern "js",extern "c", or backend-specific#cfgblocks must be listed. Pure MoonBit files (no FFI) do NOT need atargets:entry — if every file in the package is pure MoonBit, omit thetargets:block entirely. The example above listsffi.mbt/async.mbtbecause they containextern "js"; a simple library with no FFI only needslink: { ... }.
import { "moonbitlang/async", } for "test" options( targets: { "ffi.mbt": ["js"], "async.mbt": ["js"], "async_test.mbt": ["js"], }, link: { "js": { "exports": ["add", "greet"], "format": "esm", }, }, )
Key fields:
| Field | Purpose |
|---|---|
targets | Gate .mbt files to ["js"]. JS-only FFI files MUST be gated or they break other backends. |
link.js.exports | List public functions to re-export from the output module. Use "name:alias" to rename. |
link.js.format | "esm" (ES modules, default for modern bundlers), "cjs" (Node require), or "iife" (browser script tag). |
import ... for "test" | moonbitlang/async is only needed in tests (async test) — keep it out of production build. |
Warning —
supported-targetsvstargets: Do NOT usesupported-targets: ["js"]at the package level. It blocks downstream consumers. Gate individual files withtargetsinstead.
Warning —
preferred_target = "js": This is a default, not a lock.moon test --target wasm-gcstill works on files that support it — useful for cross-backend libraries.
Two ways to gate code by backend — targets: (file-level) vs #cfg() (declaration-level):
| Approach | Granularity | When to use |
|---|---|---|
targets: { "foo.mbt": ["js"] } in moon.pkg | Whole file | File is wholly JS-specific (FFI-only, backend-exclusive logic). Cleanest when the JS code doesn't need to share state with other backends. |
#cfg(target="js") on a declaration | Single fn, const, or block | A mostly-shared module needs a backend-specific implementation of one symbol. Keeps related code in one file. |
Conditions you will see inside #cfg(...):
mbt#cfg(target="js") // JS only #cfg(target="native") // native only #cfg(not(target="js")) // everything except JS #cfg(any(target="wasm", target="wasm-gc")) // either wasm backend #cfg(any(target="native", target="llvm")) // either non-GC backend #cfg(false) // always exclude (quick disable)
moonbitlang/core uses this pattern extensively — e.g. Int16::from_int64 has a %i64_to_i16 intrinsic on native/wasm but a fallback implementation on JS where 64-bit integers don't have a native representation:
mbt///| #cfg(not(target="js")) pub fn Int16::from_int64(self : Int64) -> Int16 = "%i64_to_i16" ///| #cfg(target="js") pub fn Int16::from_int64(self : Int64) -> Int16 { Int16::from_int(self.to_int()) }
The same file-level rule applies to #cfg: every target your function is callable from must have exactly one matching definition. Overlapping #cfg guards are a compile error. #cfg(target="js") + #cfg(not(target="js")) cover everything; #cfg(target="js") alone on a non-intrinsic declaration means that function does not exist on other backends and any code that calls it must itself be #cfg-gated (or live in a file gated via targets:).
Choosing between them: If the whole file is extern "js" wrappers, gate the file via targets: — simpler, and moon check --target wasm-gc skips the file entirely. If you are writing a cross-backend library where 90% of the logic is shared and only a few functions differ, reach for #cfg.
Phase 2: FFI Layer
Write extern "js" declarations. Keep them private (or prefixed ffi_); expose safe wrappers in Phase 3.
Inline JS (#| literal):
mbt///| extern "js" fn ffi_console_log(msg : String) -> Unit = #| (msg) => console.log(msg) ///| extern "js" fn ffi_sqrt(x : Double) -> Double = #| (x) => Math.sqrt(x)
The #| literal is the function body as raw JS source. The MoonBit compiler inlines it at the call site — no closure allocation at runtime.
Module-and-name form (imports an existing JS function directly):
mbt///| extern "js" fn math_max(a : Double, b : Double) -> Double = "Math" "max"
Use this when you want to reference a globally accessible function without wrapping it in a closure. Slightly faster and smaller.
#module("pkg") — compile-time ESM / CJS import:
For named exports from JS modules (Node built-ins, npm packages, local .js files), annotate the extern "js" with #module("module-id"). The right-hand side is the export name inside that module (not an inline JS body):
mbt///| #module("node:path") extern "js" fn path_basename(p : String) -> String = "basename" ///| #module("node:path") extern "js" fn path_extname(p : String) -> String = "extname"
What the compiler emits:
link.js.format | Generated code |
|---|---|
"esm" | import { basename as basename$7 } from "node:path"; |
"cjs" | const { basename: basename$1548 } = require("node:path"); |
"iife" | same CJS-style destructure (wrapped in the IIFE) |
Why #module() beats a hand-rolled require("...") wrapper:
- Statically analyzable. Vite / esbuild / Rollup can trace the dependency, tree-shake, and resolve paths correctly.
- Format-agnostic source. The same
.mbtfile works under ESM (whererequireisn't defined) and CJS without a second code path. - Smaller generated code. No per-call closure; the destructured local is reused.
- Bundler-compatible paths. Works for npm package specifiers (
"lodash"), Node built-ins ("node:path"), relative files ("./helpers.js"), and URL-style specifiers when supported by the runtime.
Constraints:
- Named exports only. The RHS must be a key exported from the module. For default exports or modules whose top-level value is the function itself, you need a bridge re-export on the JS side, or fall back to inline
import/requirein an FFI body. - One export per declaration. You cannot share a destructure across multiple
#moduleFFIs — each annotation generates its own import line. The compiler deduplicates the module specifier, so you pay onerequireper unique module, not per function. - Module-name literal must be compile-time constant. Dynamic module paths (
#module(some_variable)) are not supported. For dynamically chosen modules, fall back torequire(name)in inline JS. - No inline body allowed.
#module("pkg")+extern "js" fn f() = "name"is the only shape. Mixing with#| (...) => ...is a compile error. - Browser globals don't need it. For
window,document,fetch,Math, etc., use inline JS or the module+name form (= "Math" "max"); they are not imported, just accessed. - Side-effectful imports are still side-effectful. The
import/requirefires when the compiled module loads. Ifpkghas top-level side effects (CLI banners, global patching), they run during import even if you never call the FFI function. - Test vs release format.
moon testuses CJS harness regardless of yourlink.js.format. If a module is ESM-only (no CJS entry point inpackage.jsonexports), its tests may fail even thoughmoon build --releaseworks. Gate those tests or use a dual-format package.
When to use which form:
| Form | Use when |
|---|---|
#|(x) => ... inline | Small expression, complex logic, need to compose multiple JS calls in one wrapper |
= "Math" "max" module+name | Calling a pre-defined global function (no module import needed) |
#module("pkg") + = "name" | Calling a named export from an importable module (Node built-in, npm package, local file) |
require("pkg").get("name") helper | Dynamic module name, or module-name only known at runtime |
Multi-line inline JS:
mbt///| extern "js" fn ffi_do_work(x : Int) -> Int = #| (x) => { #| const y = x * 2; #| return y + 1; #| }
Opaque JS value pattern:
For anything without a MoonBit equivalent (DOM nodes, npm objects, undefined, null), wrap it in #external pub type:
mbt///| #external pub type JsValue ///| /// Zero-cost bitcast — compiles to nothing. pub fn[A, B] identity(a : A) -> B = "%identity"
%identity is a compiler intrinsic: it moves a value across types at the type-checker level and emits no JS code. Use it to lift primitives into JsValue and back. It is unchecked at runtime — wrong use is a silent type error.
Generic FFI operations on JsValue:
mbt///| pub extern "js" fn JsValue::get(self : JsValue, key : String) -> JsValue = #| (obj, key) => obj[key] ///| pub extern "js" fn JsValue::set( self : JsValue, key : String, value : JsValue, ) -> Unit = #| (obj, key, value) => { obj[key] = value } ///| pub extern "js" fn JsValue::call_method( self : JsValue, name : String, args : Array[JsValue], ) -> JsValue = #| (obj, name, args) => obj[name](...args)
These three primitives ( get / set / call_method ) can express virtually any JS object interaction.
Null / undefined distinction:
MoonBit T? cannot represent T | null | undefined safely — you'd get Some(null). Check explicitly with is_undefined / is_null:
mbt///| pub extern "js" fn is_undefined(v : JsValue) -> Bool = #| (v) => v === undefined ///| pub fn[T] js_get_opt(obj : JsValue, key : String) -> T? { let v = obj.get(key) if is_undefined(v) { None } else { Some(identity(v)) } }
For libraries that distinguish null from undefined (e.g. FileReader.error), introduce Nullable[T] and Nullish[T] wrapper types and narrow with explicit checks.
Phase 3: MoonBit API
Build safe, typed, pub wrappers over the raw ffi_* externs.
mbt///| pub fn console_log(msg : String) -> Unit { ffi_console_log(msg) } ///| pub fn sqrt(x : Double) -> Double { if x < 0 { panic() } ffi_sqrt(x) }
Exports to JS consumers:
Anything listed in moon.pkg link.js.exports must be pub. The compiler emits a named ESM export (or CJS property, or IIFE global) for each. A .d.ts is generated automatically from the public signature.
#export_name(v0.10.4) pins the emitted symbol name on a singlepub, non-generic function and is now preferred over backend-specificexportslink configuration for new exports. It does not work on generic functions or functions with optional arguments, and the name must be a valid C identifier that is unique in the package.mbt#export_name("add_ints") pub fn add(a : Int, b : Int) -> Int { a + b }Export configuration is scoped to the package that produces the artifact — an attribute inside a dependency does not add a symbol to your output.
Phase 4: Promise / Async Bridging
MoonBit async uses two compiler intrinsics that translate to JS Promise internals:
| Intrinsic | Role |
|---|---|
%async.suspend | Convert a resume/reject callback pair into an await point |
%async.run | Start an async computation from a sync context |
Canonical setup (see references/promise-bridging.md for full detail). Declare in this order — each item depends on the previous:
mbt///| #external pub type Promise[T] ///| /// Cast Promise[T] to JsValue so JsValue methods (get/call_method) apply. /// Required by `Promise::wait` below. pub fn[T] Promise::to_any(self : Promise[T]) -> JsValue = "%identity" ///| pub async fn[T, E : Error] suspend( f : ((T) -> Unit, (E) -> Unit) -> Unit, ) -> T raise E = "%async.suspend" ///| pub fn run_async(f : async () -> Unit noraise) -> Unit = "%async.run" ///| /// The bridge. `await promise` in JS = `promise.wait()` in MoonBit. pub async fn[T] Promise::wait(self : Promise[T]) -> T { suspend(fn(ok, err) { self.to_any().call_method("then", [identity(fn(v : JsValue) { ok(identity(v)) })]) .call_method("catch", [identity(fn(e : JsValue) { err(identity(e)) })]) |> ignore }) }
When do you need %async.run vs just %async.suspend?
| Situation | Need run_async? | Why |
|---|---|---|
async test "..." { ... } block | No | The test harness already runs the block in an async context |
pub async fn calling .wait() | No | The caller is already async; await chains naturally |
| Exported MoonBit sync function that kicks off a Promise and returns it to JS | Yes | You are crossing sync → async; run_async schedules the body |
Constructing a Promise[T] from an async fn executor | Yes | The executor body must run even though new Promise(...) is sync |
In short: async test users never write run_async. It is needed only when %identity-casting async computations into JS Promise land from a sync context.
Standard pattern — FFI returning Promise + async wrapper:
mbt///| extern "js" fn ffi_fetch_text(url : String) -> Promise[String] = #| (url) => fetch(url).then(r => r.text()) ///| pub async fn fetch_text(url : String) -> String { ffi_fetch_text(url).wait() }
Callers write let body = fetch_text(url) inside an async test or async fn — no .wait() needed at the call site. Tests:
mbtasync test "fetch_text resolves" { let body = fetch_text("https://example.com") inspect(body.length() > 0, content="true") }
Note —
moonbitlang/async:async testblocks requireimport { "moonbitlang/async" } for "test"inmoon.pkg. The dependency is only needed at test time. Add withmoon add moonbitlang/async— version 0.17.0 or later is required (older 0.1.x releases don't support the current%async.suspendABI). Verified with0.21.0.Breaking changes to watch when upgrading past 0.20:
@http.Headersis now a case-insensitive map rather thanMap[String, String];@fsdropped thecreate/truncateparameters and@fs.write_filenow defaults toCreateOrTruncate;@stdio.{Input,Output}::fdraises instead of returning;@async.protect_from_canceldefaults toresume_on_cancel=true. The runtime also aborts on detected deadlocks (@async.set_deadlock_handlerto configure).
Phase 5: Testing
bashmoon check # type-check, fast moon test # run all tests (js is default) moon test src --filter "greet" # filter by name moon build # emit .js + .d.ts to _build/js/release/build/
Build output paths — debug vs release:
| Command | Output directory |
|---|---|
moon build (default) | _build/js/debug/build/<pkg>.js + .d.ts |
moon build --release | _build/js/release/build/<pkg>.js + .d.ts |
moon test (auto) | _build/js/debug/test/<pkg>.{internal,blackbox}_test.js |
.d.ts is generated in both modes. For publishing to npm, use --release. For local experimentation, the default debug output is sufficient and links faster.
Running the generated module from Node:
bash# debug build node --input-type=module -e 'import("./_build/js/debug/build/<pkg>.js").then(m => console.log(m.add(2,3)))' # release build (after `moon build --release`) node --input-type=module -e 'import("./_build/js/release/build/<pkg>.js").then(m => console.log(m.add(2,3)))'
A runnable end-to-end example lives under assets/js_binding_proj/ in this skill — see the directory for a minimal project with 8 passing tests (sync + async) that you can copy, adapt, and run with moon test.
Decision Table
| Situation | Pattern | Key Action |
|---|---|---|
| Call a JS global (one-liner) | Inline #| with arrow function | extern "js" fn ... = #| (x) => ... |
| Reference existing global (no wrap) | Module+name form | = "Math" "max" |
| Named export from npm / Node built-in | #module("pkg") + = "exportName" | Compiler emits import { exportName } or require, bundler-friendly |
| Opaque JS object | #external pub type T | Wrap + cast with %identity |
JS any container | Single JsValue type with get/set/call_method | Build object-protocol helpers once, reuse |
T | undefined | Check is_undefined(v), return T? | Never assume Some(null) is meaningful |
T | null (distinct from undefined) | Nullable[T] wrapper type | Explicit is_null branch |
| JS Promise | #external type Promise[T] + wait() | Bridge via %async.suspend |
| Export to JS consumers | link.js.exports in moon.pkg | List pub functions; .d.ts auto-generated |
| Gate a whole file to one backend | targets: { "f.mbt": ["js"] } in moon.pkg | Cleanest when the whole file is backend-specific |
| Gate a single function to one backend | #cfg(target="js") above the fn | Use when 90% of the file is shared; pair with #cfg(not(target="js")) for the other backends |
Large integer (|x| > 2^53) | BigInt, not Int64 | JS number loses bits above 2^53 |
| Call an npm package | Wrap require("pkg") → JsValue.get("fn") | See references/interop-patterns.md |
| Catch a JS exception | Wrap call in try { ... } catch { ... } on JS side | See references/error-handling.md |
Common Pitfalls
-
Forgetting to gate files with
targets. A file containingextern "js"that isn't gated to["js"]breaksmoon check --target wasm-gcandnative. Always gate FFI files. -
Using
T?to modelT | null | undefined. MoonBit'sNoneis not JSnull, andSome(null)is a nonsense value. Split withis_undefinedandis_nullbefore lifting. -
Relying on
Int64across FFI. JSnumberis IEEE-754 double. Any 64-bit integer > 2^53 silently rounds. UseBigIntor split into twoInts. -
Exposing MoonBit internals by passing
Map/Result/trait objects directly. These have a MoonBit-specific runtime layout. Convert to plain structs,Array[(K, V)], orJsValueat the boundary. -
Missing
moonbitlang/asyncforasync test. The test-only import is easy to forget. Addimport { "moonbitlang/async" } for "test"tomoon.pkgbefore writing the firstasync test. -
Forgetting
%async.runwhen calling async from sync JS. Exported sync functions can'tawait. If you need to fire an async operation from a sync export, wrap it withrun_async(async fn() noraise { ... })and return aPromiseexplicitly. -
Calling
.wait()without being in anasynccontext..wait()isasync— it only compiles inside anasync fnorasync test. Non-async callers need the Promise directly. -
Using
%identitywhere a real conversion is needed.%identityis a type-checker escape hatch with no runtime effect. Using it to castInt↔Stringwill produce a runtime error with no warning. Only use when the JS runtime representation genuinely matches. -
Exporting a
pubfunction that isn't inlink.js.exports. It is callable from other MoonBit packages but not re-exported from the compiled JS module. Add the name toexports.
Example Project
A minimal, runnable project demonstrating every pattern above:
assets/js_binding_proj/ ├── moon.mod # preferred_target = "js", moonbitlang/async dep └── src/ ├── moon.pkg # targets + link.js.exports ├── ffi.mbt # extern "js" + JsValue + inline JS ├── lib.mbt # Safe public wrappers (add, greet, js_get_opt) ├── lib_test.mbt # Sync tests ├── async.mbt # Promise[T] + suspend + wait ├── async_test.mbt # async test blocks ├── modules.mbt # #module("node:path") ESM/CJS import demo ├── modules_test.mbt ├── cross_target.mbt # #cfg(target="...") per-declaration gating demo └── cross_target_test.mbt
Run:
bashcd assets/js_binding_proj moon test # 12 tests pass (sync + async + #cfg + #module) moon build # emits ESM + .d.ts node --input-type=module -e 'import("./_build/js/debug/build/js_binding_proj.js").then(m => console.log(m.add(2,3), m.greet("world"), m.basename("/a/b/c.html")))' # → 5 Hello, world! c.html
References
@references/promise-bridging.md @references/interop-patterns.md @references/error-handling.md @references/typescript-integration.md @references/cfg-and-target-gating.md

