iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐇

Replacing TypeScript with Moonbit using mizchi/js.mbt

に公開

This is day 7 of the MoonBit Advent Calendar: https://qiita.com/advent-calendar/2025/moonbit

I am building a collection of JS bindings for MoonBit called https://github.com/mizchi/js.mbt. If you have MoonBit installed, you can use it with moon add mizchi/js.

Within the scope of mizchi/js, you can use Node.js and React using only MoonBit, without writing any JS bridge code. I have prepared sample applications that are actually running, such as a React SPA, Cloudflare Worker, AI SDK, MCP Client/Server, and JSX SSR for Hono.

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

It was also used in yesterday's article about building Tetris in MoonBit. Thank you very much.

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

(However, due to bundle size concerns, I had to significantly change the API in a recent update. I will describe this later in this article.)

I will introduce the intent behind building this and what you can do with it.

What is needed to use MoonBit like TypeScript?

Around 2014, early TypeScript, which did not have as much expressive power as it does now, had many parts that had to be expressed with any, but it was still better than having no type definitions at all.

Since TypeScript itself became mainstream around 2019, libraries that assume TypeScript's inference capabilities, such as zod and prisma, began to emerge. Library authors also started designing interfaces with the premise of adding types.

MoonBit is not at that phase yet, but since I know the gradual history until TypeScript became popular, I decided to first clarify what is needed and implement them cost-effectively.

  • Bindings to JS built-ins
    • Equivalent to TypeScript's lib.d.ts and lib.dom.d.ts
  • Bindings to Node.js
    • Equivalent to @types/node
  • Bindings to frequently used npm packages
    • Equivalent to @types/* in DefinitelyTyped
  • Bindings for the React ecosystem

By providing these to MoonBit, I decided to examine whether it is possible to create a situation where you can use MoonBit instead of TypeScript for the time being.

My reasons for wanting to use MoonBit are that its capabilities as a programming language are straightforwardly excellent, and it can generate wasm/native code in addition to JS. I often encounter situations where AI fails due to the lack of expressive power in TypeScript.

Implementation steps for mizchi/js

I set the goal that React and Cloudflare Workers, which I often write, should run. First, I created bindings that allow these to work in the shortest possible time and verified their operation.

Therefore, the first part I created on my own is this repository. (It now serves as a reference implementation when using the reference of mizchi/js)

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

After that, I refined the API of the tentative mocks by referring to Web standard specifications, and generated a large amount of FFI to allow comprehensive access to the API within the scope of Web standards.

Actually, I wrote the core FFI and reference implementations for each package, and the majority was generated by feeding MDN/Node documentation to an AI.

Below, I will implement in the order of core, browser, node, and npm libraries.

mizchi/js/core

This package contains bindings for primitive values and utilities for unsafe casting.

You should understand it by looking at it.

// 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()

This is intended for internal use by those using the bindings. You cast to the Any type with @core.any(), and cast back to any arbitrary MoonBit type with Any::cast(). Since this can easily lead to incorrect code, it is intended to be used at your own risk, just like any in TypeScript.

In the JS abstraction, as long as you can perform get/set/function calls on properties, you can perform basic operations by simply wrapping them.

mizchi/js/builtins/*

These are wrappers for JS built-in objects.

It should cover everything from basic APIs like Date, RegExp, ArrayBuffer, and Math to things like Atomics and FinalizationRegistry. Even if they aren't used frequently, I thought it was bad to have things accessible only in JS, so I completed the bindings while checking the list of built-ins on MDN.

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

Global functions like parseInt and encodeURIComponent are included in mizchi/js/builtins/global.

Basically, if you write in MoonBit, it is cleaner to use MoonBit's expression libraries, but depending on the API used, the build size will increase. For example, the build size for regular expressions (RegExp) is much smaller when using it from mizchi/js.

I intentionally did not implement eval() and new Function(). Having these would make any dangerous code possible. (Write your own extern if you need them.)

mizchi/js/web/*

These are platform-specific APIs.

I support APIs like console.*, fetch, websocket, Crypto, and Performance here.

Although these fall outside the scope of EcmaScript, I have placed them here because they are usable in Deno, Cloudflare, browsers, and (some of) Node.js.

Example:

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)

I also support WebGPU within the scope of Deno's support (because Deno doesn't require going through a browser, making unit tests easier to write). It fell apart a bit because I haven't kept up with the latest APIs...

Regarding this section, there are simply too many things that potentially need to be implemented—I would like to support WebGL, WebAudio, and WebTransport eventually—but for now, I have implemented the scope supported by WinterTC55.

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

These are intended to run on Deno, Bun, and Cloudflare as well.

mizchi/js/node

These are Node.js-based APIs, similar to @types/node.

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

Referring to the nodejs.org reference, I have supported items that are not deprecated and do not have substitute APIs in the Web standard. I generated bindings for most of the Node.js standard library.

For example, here is a test code example using 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)
}

Even though MoonBit has async, it does not have await; it tracks whether a call is asynchronous from its type signature.

mizchi/js/browser/*

These are APIs related to the DOM.

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

I am writing tests using jsdom.

In reality, the API surface here is too vast to claim full support, but I have covered areas around DOM API, Canvas, history, location, navigator, and MutationObserver.

Ideally, I would have liked to generate these from WebIDL, but after experiencing how unusable the auto-generated code from Rust's wasm-bindgen + web_sys can be, I have been organizing the APIs manually.

mizchi/js/npm/*

Following the image of DefinitelyTyped, I have been adding type definitions to libraries I want to use, or libraries that are commonly used. There are now nearly 50 of them.

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

I am prioritizing test utilities to ensure I can write code with confidence.

Frontend-related support is being deferred because of ESM-related reasons (discussed later).

Re-evaluating the Strengths of TypeScript

I felt that MoonBit is essentially different from TypeScript because TypeScript's ability to derive types from code to some extent without writing types upfront is extremely powerful, while simultaneously being a reason for code instability.

For instance, I wrote Zod bindings for MoonBit. Even if validation is possible with this code, type inference is, as of now, impossible no matter how hard I try. (Perhaps such changes might be introduced in the future...)


///|
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(_))
}

For MoonBit to support this, it would have to automatically generate code. I have tried writing a tool that automatically generates MoonBit code from Zod schemas. This approach feels like a valid one.

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

I also tried writing Drizzle bindings, but since there is no type safety, I felt that I personally had no reason to use them.

Design Changes

MoonBit allows default implementations for traits, and I was using this to express inheritance halfway through.

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

I created a JsImpl type like this and used it in an inheritance-like manner.

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)
}

I had built up to v0.6 using this, but upon checking the generated code, it was disastrous.

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: ... }
);

Every time a trait was used, the internal trait object was expanded inline and then discarded unused. For every function call taking a trait as an argument, 13 methods were redefined 13 times, nearly none of which were used.

I also generated code for inheritance-heavy structures like EventTarget, Node, Element, and HTMLElement using this pattern, but it ended up terrible, so I stopped.

When I actually built and checked the React template code, there were 448 function calls, and their inlining accounted for 40% of the generated code. I couldn't leave it as is, so I made breaking changes to the API, prioritizing performance while accepting a certain degree of inconvenience.

By dropping the trait, the build size of my local React application decreased from 69k to 27k.

Furthermore, I wrote an AST Transformer that inlines simple getter FFIs and function call FFIs against the post-build JS AST, and found that it further reduced the size from 27k to 25k.

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

I feel like I could make it even more effective, so I'll release this too if I feel like it. (Ideally, I should contribute to the MoonBit compiler itself, though.)

Current Limitations

With last week's 2025-12-02 update, it now supports ESM.

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

I tried it and it built correctly with moon build. However, only during moon test, the import {minify} from "terser" was missing, and it didn't work. (I have already reported this as a bug.)

I actually want to migrate the React and Hono areas to ESM, but since I cannot port the test code, I am waiting for support.

Once this is handled, I plan to start porting frontend UI libraries. (Currently, the node-related parts are using CJS require.)

Thoughts After Building It

To use MoonBit right now, I generated a massive amount of FFI bindings equivalent to .d.ts files.

Since I'm building it individually, I don't think I can build an ecosystem as stable as TS, but in areas with few library dependencies and deep domains, I feel MoonBit is already easier to write in.

Regarding safety, MoonBit has an excellent type system and exception semantics, so it unexpectedly works out. Being able to write JS using a Rust-like language and a Cargo-like toolchain is surprisingly comfortable.

However, I feel that to have AI generate MoonBit code, I need to feed it a large amount of examples with correct syntax. In that sense, token consumption is a bit disadvantageous.

Discussion