🐇

mizchi/js.mbt で TS の代わりに Moonbit を書く

に公開

https://qiita.com/advent-calendar/2025/moonbit の7日目です。

https://github.com/mizchi/js.mbt というMoonbitのJSバインディング集を作っています。Moonbit がインストールされていれば、 moon add mizchi/js で使えるようになります。

mizchi/js のサポート範囲では JS のブリッジコードを書くことなく、Moonbit のみで Node.js や React を利用することができるようになります。実際に動いているサンプルとして React SPA, Cloudflare Worker, AI SDK, MCP Client/Server, Hono のJSX SSR を用意しています。

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

昨日のMoonbitでテトリスを作ってみた記事でも使われていました。ありがとうございます。

https://zenn.dev/alphatique/articles/3c6b334ac45780

(ただ直近の変更で、バンドルサイズを理由にAPIを大幅に変更せざるを得なくなりました。この記事で後述します)

これをどういう意図で作っているか、何ができるかを紹介します。

TypeScriptのようにMoonbitを使うには何が必要?

2014年頃、今ほどの表現力がなかった初期のTypeScriptは、今よりany で表現せざるものが多かったですが、それでも全く型定義がないよりは嬉しいものでした。

2019年頃から TypeScript そのものが主流になったことで、zod や prisma のような TypeScript の推論能力を前提としたライブラリが生まれてきます。ライブラリ作者も、型をつける前提でインターフェースを設計するようになりました。

Moonbit はまだ全然そのフェーズにありませんが、自分は TypeScript が流行るまでの段階的な歴史を知っているので、まず何が必要かを洗い出して、それらをコスパ良く実装したいと考えました。

  • JS ビルトインへのバインディング
    • typescript の lib.d.ts, lib.dom.d.ts 相当
  • Node.js へのバインディング
    • @types/node 相当
  • よく使われる npm パッケージへのバインディング
    • @types/* の definitely typed 相当
  • React 周辺のバインディング

これらを Moonbit に提供することで、とりあえず TypeScript の代わりに Moonbit を使うことが可能という状況が作れるかどうかを検討することにしました。

Moonbit を使いたい理由は、プログラミング言語としての能力が素直に優秀なのと、JS 以外に wasm / native のコードが生成できるからです。 TypeScript の表現力の不足ゆえにAIが破綻する場面に、最近よく遭遇します。

mizchi/js の実装手順

自分が実際に書くことが多い React と Cloudflare Worker が動く、という点をゴールに設定します。それらがまず最短で動くバインディングを作成して、動作確認をします。

なので、手元で最初に作ったのがこのリポジトリの部分です。(今は mizchi/js のリファレンスを使う際のリファレンス実装になっています)

https://github.com/mizchi/moonbit-react-example

その後、仮置きのモックをWeb標準の仕様を参考にAPIを整えて、Web標準の範囲でAPIに網羅的にアクセスできるようにFFIを大量に生成しました。

実際には、自分はコアのFFI周辺と各パッケージのリファレンス実装を書いて、大部分はAIにMDN/Nodeのドキュメントを食わせて生成しました。

以下、core, browser, node, npm ライブラリという順番で実装していきます。

mizchi/js/core

プリミティブな値へのバインディングと、unsafeなキャストのユーティリティです。

みたらわかると思います。

// const obj: any = Object.fromEntries([["name", "Alice"], ["age", 30]]);
let obj = @core.from_entries([
  ("name", @core.any("Alice")),
  ("age", @core.any(30))
])

// const name = obj.name
let name = obj["name"]

// obj.age = 31
obj["age"] = @core.any(31)

// const result = obj.toString()
let result = obj._call("toString", [])

// const age: number = obj.age
let age: Int = obj["age"].cast()

これ自体はバインディングを使う人が内部で使うのを意図していて、 @core.any() で Any 型にキャストして、Any::cast() でMoonbitの任意の型に再キャストします。これ単独ではいくらでも嘘が書けるので、TypeScriptの any と同じく、自己責任でキャストする用途です。

JS抽象において、プロパティへの get/set/関数呼び出しが可能ならば、あとはそれらをラップするだけで基本的な操作が可能になります。

mizchi/js/builtins/*

JS のビルトインオブジェクトへのラッパーです。

Date, RegExp, ArrayBuffer, Math のような基本的なAPIから、 AtomicsFinalizationRegistry のようなものまで大体揃ってるはずです。使用頻度は高くなくてもJSでしかアクセスできないものがあるのが良くないと思い、MDNのビルトイン一覧を確認しながらバインディングを揃えました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects

parseIntencodeURIComponent のようなグローバルな関数は、 mizchi/js/builtins/global に入っています。

基本的に Moonbit で書くなら Moonbit の表現ライブラリに寄せた方がコードとしては綺麗になるんですが、使うAPIによってはビルドサイズが膨れてしまいます。例えば正規表現の RegExp は、mizchi/js から使う方がビルドサイズが断然小さいです。

意図的に eval()new Function() は実装していません。これがあると危険なコードが何でもありになっちゃうので。(自分で extern を書きましょう)

mizchi/js/web/*

プラットフォーム系のAPIです。

console.* 系のAPIや、fetch, websocket, Crypto, Performance なんかをここでサポートしています。

EcmaScript の範囲からは外れるけども、 deno/cloudflare/browser/node(の一部) でも使えるものはここに所属させています。

let crypto = @crypto.Crypto::get()
let subtle = crypto.subtle
let data : @core.Any = @core.identity(
  @encoding.TextEncoder::new().encode("test").as_any(),
)
let digest = subtle.digest("SHA-256", data)

webgpu も deno のサポート範囲だけサポートしてます。(deno ならブラウザを通す必要がなく、ユニットテストが書きやすいからです。なんか最新APIに追従してないので色々落ちましたが...)

ここに関しては潜在的に実装しないといけないものがあまりにも多く、例えば WebGL, WebAudio や WebTransport もサポートしたいんですが、一旦は WinterTC55 のサポート範囲を実装しています。

https://github.com/WinterTC55/proposal-minimum-common-api

これらは deno / bun / cloudflare でも動かすのを想定しています。

mizchi/js/node

node.js 系のAPIです。@types/node のようなものです。

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

https://nodejs.org のリファレンスを参考に、 Deprecated になってないもの、Web標準で代替APIがないものを対応しました。

node.js の標準ライブラリのほとんどにバインディングを生成しました。

例えば、 node:fs/promises を使うテストコードの例です。


///|
async test "Node FS Promises: readFile and writeFile" {
  let test_file = "test_async_write.txt"
  let content = "Hello, async world!"
  @fs_promises.writeFile(test_file, content)
  let data = @fs_promises.readFile(test_file)
  assert_eq(data, content)
  @fs_promises.unlink(test_file)
}

Moonbit は async はあっても await がなく、呼び出しの型シグネチャから非同期かどうかを追跡します。

mizchi/js/browser/*

DOM 周辺のAPIです。

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

jsdom でテストを書いています。

実際ここは API が膨大すぎて全部サポートしてるとは言えない状況なんですが、DOM API, Canvas, history, location, navigator, MutationObserver 周辺に対応しました。

本当だったら webidl から生成したいところなんですが、Rust wasm-bindgen + web_sys で自動生成コードがあまりにも使いづらかった経験から手作業でAPIを整理しています。

mizchi/js/npm/*

DefinitelyTyped のイメージで、自分が使いたいライブラリ、あるいは使われてるライブラリに型をつけていきました。今は50近くになっています。

https://github.com/DefinitelyTyped/DefinitelyTyped

  • ai
  • ajv
  • better-auth
  • chalk
  • chokidar
  • claude_code
  • awssdk/client-s3
  • comlink
  • date-fns
  • debug
  • dotenv
  • drizzle
  • duckdb
  • error-stack-parser
  • esbuild
  • execa
  • global-jsdom
  • helmet
  • hono
  • htmlparser2
  • ignore
  • ink
  • ink-testing-library
  • ink-ui
  • jose
  • js-yaml
  • lighthouse
  • magic-string
  • memfs
  • minimatch
  • modelcontextprotocol
  • msw
  • oxc-minify
  • pg
  • pglite
  • pino
  • playwright
  • preact
  • puppeteer
  • react
  • react-dom
  • react-router
  • semver
  • sharp
  • simple-git
  • source-map
  • terser
  • @testing-library/core
  • @testing-library/preact
  • @testing-library/react
  • @testing-library/vue
  • unplugin
  • vite
  • vitest
  • vue
  • yargs
  • zod

安心して書くためのテストユーティリティを優先しています。

ESM周辺のサポートに理由があってフロントエンド周りを後回しにています。(後述)

再確認した TypeScript の強み

Moonbit が本質的に TypeScript と違う感じたのは、TypeScript は先に型を書かなくてもある程度コードから型を導出できるという点が非常に強力であり、またそれがコードが不安定になる理由でもあると感じました。

例えば、Moonbit で zod のバインディングを書いたのですが、このコードでバリデーションはできても、型推論を行うのは、今のところどう頑張っても無理です。(もしかしたら、今後そういう変更が入るかもしれませんが...)


///|
test "usage example object partial required" {
  let schema = @zod.object({
    "name": @zod.string(),
    "age": @zod.number(),
    "items": @zod.array(@zod.string()),
  })
  let val = @js.from_entries([
    ("name", "Alice" |> any),
    ("age", 30 |> any),
    ("items", [1, 2, 3] |> any),
  ])
  assert_true(schema.safeParse(val) is Ok(_))
}

Moonbit がこれに対応するには、コードを自動生成するしかありません。試しに zod schema からMoonbitのコードを自動生成するものを書いてみたりしています。アプローチとしてはこれは真っ当な気がします。

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

試しに drizzle バインディングも書いてみたのですが、型安全性がないので自分でも使う理由がないように感じました。

設計変更

Moonbit は trait にデフォルト実装を持つことができ、途中までこれを使って継承を表現していました。

https://www.moonbitlang.com/pearls/oop-in-moonbit

このように JsImpl 型を作って、これを継承的に使っていました。

pub(open) trait JsImpl {
  to_any(Self) -> Any = _
  get(Self, &PropertyKey) -> Any = _
  set(Self, &PropertyKey, &JsImpl) -> Unit = _
  call(Self, &PropertyKey, Array[&JsImpl]) -> Any = _
  call0(Self, &PropertyKey) -> Any = _
  call1(Self, &PropertyKey, &JsImpl) -> Any = _
  call2(Self, &PropertyKey, &JsImpl, &JsImpl) -> Any = _
  call_throwable(Self, &PropertyKey, Array[&JsImpl]) -> Any raise ThrowError = _
  call_self(Self, Array[&JsImpl]) -> Any = _
  call_self0(Self) -> Any = _
  call_self_throwable(Self, Array[&JsImpl]) -> Any raise ThrowError = _
  delete(Self, &PropertyKey) -> Unit = _
  hasOwnProperty(Self, &PropertyKey) -> Bool = _
}

impl JsImpl with get(self, key : &PropertyKey) -> Any {
  ffi_get(self.to_any(), key.to_key() |> identity)
}

これで v0.6 まで作っていたのですが、生成コードを確認すると、酷いことになっていました。

mizchi$js$$JsImpl$call1$9$(
  mizchi$js$$JsImpl$call1$15$(self,
    { self: "then", method_0: mizchi$js$$PropertyKey$to_key$3$ },
    { self: _cont, method_0: ..., method_1: ..., ..., method_12: ... }
  ),
  { self: "catch", method_0: mizchi$js$$PropertyKey$to_key$3$ },
  { self: _err_cont, method_0: ..., method_1: ..., ..., method_12: ... }
);

trait を使うたびに、インラインで内部の trait オブジェクトが展開され、そして使われずに破棄されます。trait を引数に持つ関数呼び出しのたびに13個のメソッドが13個が再定義され、ほぼ使われません。

EventTarget, Node, Element, HTMLElement のような継承を前提としたコードもこのパターンで生成したのですが、ひどいことになったのでやめました。

実際にReactのテンプレートのコードをビルドして確認すると、448回の関数呼び出しがあり、そのインライン展開が生成コードの 40% を占めていました。さすがにこのままにするわけにいかないので、ある程度の使いづらさを許容してパフォーマンスを優先してAPIを破壊的に変更しました。

trait をやめたことで手元のReactアプリケーションのビルドサイズは 69k => 27k に減少しました。

また、ビルド後のJSのASTに対して、素朴な getter FFI や関数呼び出しFFIをインライン化するAST Transformer を書いてみたところ、 27k => 25k に減ることがわかっています。

https://github.com/mizchi/js.mbt/tree/main/packages/moonbit-optimize-plugin

もう少し効果を大きくできそうな気がするので、気が向いたらこれも公開します。(本当はMoonbitコンパイラ自体に手を入れた方がいいんですが)

今の制限

先週の 2025-12-02 のアップデートで、なんと ESMに対応しました。

#module("terser")
extern "js" fn terser_minify(
  code : String,
  options : @core.Any,
) -> @core.Promise[@core.Any] = "minify"

試してみたところ moon build で正しくビルドされました。が、 moon test の時のみ import {minify} from "terser" が欠落してしまって動きませんでした。(バグとして報告済みです)

本当は React と Hono 周りを ESM に移行したいんですが、テストコードを移植できないので、対応待ちです。

これが対応されたらフロントエンドのUIライブラリを移植していく予定です。(今は node 関連は cjs のrequire になっています。)

作ってみた感想

Moonbit をいますぐ使うため、 .d.ts 相当のFFIバインディングを大量に生成しました。

個人で作ってるのでさすがにTSほど安定したエコシステムは構築できないとは思いますが、ライブラリ依存が少なくドメインが深い部分では moonbit の方が既に書きやすいと感じるところもあります。

安全性の部分でも Moonbit の優秀な型システムや例外セマンティクスがあるので、意外となんとかなります。Rust風の言語とcargo風のツールチェインで、JSが書けるというのは思いの外快適です。

ただAIにMoonbitのコードを生成させるには、正しいシンタックスの大量の examples を食わせる必要があるように感じています。トークン消費はその点やや不利です。

Discussion