🐰

Moonbit がもう少しで実用できそうな気配

に公開

現在の Moonbit で OpenAI を叩くコードを書いてみて、その肌感をまとめた記事です

現在の Moonbit のステータス

現在、Beta リリースです。2026 年に 1.0 を予定しているそうです。

ここ最近の変更を見る限り、wasm がファーストターゲットというより、js/native/wasm のクロスプラットフォーム言語、という感じになってきます。

https://www.moonbitlang.com/blog/moonbit-value-type

moonbitlang/async で OPENAI API を叩くコードを書いてみた

CLI で HTTP Request 投げて JSON パースできて表示できたら、実用的に必要な大体のコードは理論上書ける(はず)

というわけで、まず必要なパーツを揃ってるかを確認します。

  • 環境変数を取得する moonbitlang/x/sys@sys.get_env_vars()
  • 引数を参照する moonbitlang/core/env の @env.args()
  • HTTP Request を送る moonbitlang/async/http@http.post
  • JSON をパースして struct にマッピングする dervice(FromJson, ToJSon)

moon new myapp でプロジェクトを生成します。

# インストール
$ curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash

$ moon new myapp
$ cd myapp
$ moon run cmd/main
Hello World

スキャッフォルドが動いてることを確認して、mooncakes から実験的な準標準ライブラリを追加

$ moon add moonbitlang/x
$ moon add moonbitlang/async

moon.mod.json の deps に追加されます。

使う側のディレクトリの cmd/main/moon.pkg.json で import を宣言します。

cmd/main/moon.pkg.json
{
  "is-main": true,
  "import": [
    "moonbitlang/x/sys",
    "moonbitlang/async",
    "moonbitlang/async/io",
    "moonbitlang/async/fs",
    "moonbitlang/async/pipe",
    "moonbitlang/async/http"
  ]
}

あとは雰囲気でコードを読んでください

cmd/main/main.mbt
///|
pub struct MessageContent {
  type_ : String // type が予約語なので、 type_ にマッピング
  text : String
} derive(Show, FromJson(fields(type_(rename="type"))))

///|
pub enum OutputItem {
  Message(id~ : String, role~ : String, content~ : Array[MessageContent])
  Reasoning(id~ : String)
} derive(Show, FromJson)

///|
/// Array[OutputItem] に対して FromJson を実装
pub struct Output(Array[OutputItem]) derive(Show)

///|
impl @json.FromJson for Output with from_json(
  json : Json,
  json_path : @json.JsonPath,
) -> Output raise {
  guard json is Array(items) else {
    raise @json.JsonDecodeError((json_path, "Expected an array"))
  }
  let output = items.mapi(fn(i, item) {
    guard item is Object(item) else {
      raise @json.JsonDecodeError(
        (json_path.add_index(i), "Expected an object"),
      )
    }
    match item {
      {
        "type": String("message"),
        "id": String(id),
        "role": String(role),
        "content": Array(content),
        ..
      } =>
        OutputItem::Message(
          id~,
          role~,
          content=content.mapi(fn(j, c) {
            @json.from_json(c, path=json_path.add_key("content").add_index(j))
          }),
        )
      { "type": String("reasoning"), "id": String(id), .. } =>
        OutputItem::Reasoning(id~)
      _ =>
        raise @json.JsonDecodeError(
          (json_path.add_index(i), "Unknown output type"),
        )
    }
  })
  Output(output)
}

///|
pub struct OpenaiResponse {
  id : String
  output : Output
} derive(Show, FromJson)

///|
struct OpenaiRequestBody {
  model : String
  input : String
} derive(Show, ToJson)

///|
/// run openai
async fn run_openai(input : String, api_key : String) -> Unit {
  let req : OpenaiRequestBody = { model: "gpt-5", input }
  let log = StringBuilder::new()
  let bearer : Bytes = @encoding/utf8.encode("Bearer \{api_key}")
  let body = req.to_json() |> Json::stringify |> @encoding/utf8.encode
  let (_res, bytes) = @http.post("https://api.openai.com/v1/responses", body, headers=[
    @http.Header(b"Authorization", bearer),
    @http.Header(b"Content-Type", b"application/json; charset=utf-8"),
  ])
  guard _res.code == 200 else {
    log.write_string("Error: Response code is \{_res.code}\n")
    log.write_string(@encoding/utf8.decode(bytes))
    println(log.to_string())
    return
  }
  log.write_string(@encoding/utf8.decode(bytes))
  let json_str = log.to_string()
  let parsed_response : OpenaiResponse = json_str
    |> @json.parse
    |> @json.from_json
  // println(parsed_response)
  for output in parsed_response.output.0 {
    match output {
      OutputItem::Message(content~, ..) =>
        for c in content {
          println(c.text)
        }
      _ => ignore(output)
    }
  }
}

///|
fn main {
  let input = @env.args().get(1).unwrap_or("Hello!")
  let api_key = @sys.get_env_vars().get("OPENAI_API_KEY")
  guard api_key is Some(api_key) else {
    println("Error: OPENAI_API_KEY not set")
    return
  }
  @async.with_event_loop(fn(_root) { run_openai(input, api_key) }) catch {
    err => println(err.to_string())
  }
}

これを実行します

$ moon run cmd/main -- "フランスの首都は?"
フランスの首都はパリです

言語仕様について補足

  • moon.pkg.json の import の名前空間の最後のパスでアクセスできます (例moonbitlang/x/sys => @sys)
  • 標準ライブラリ(moonbitlang/core/*) は常に読み込まれています。 @json / @strconv
  • param~ は enum や関数で使えるキーワード引数兼パターンマッチ構文みたいなノリで理解してください
  • -> Output raise は例外の型シグネチャ

書き味

「言語オタクの作った、Rust を知ってたらなんか書ける言語」という書き味です。高水準な Rust ならこう書けるはずだ、という直感でほぼコードが書けます。

IDE サポートが強力で、補完される通りに実装してたらだいたい実装できます。補完も聞くし、リネームも効くし、定義ジャンプもしっかりできます。

このコードを書いたほとんどの時間は、json のパターンマッチを調べる時間でした。JSON リテラルがビルトインとなっているんですが、実際にこれをどう書けばいいかは、ドキュメントが足りなくて悩ましい感じです。

FromJson で自動で from_json を導出できるような気がしたんですが、TS でいう ユニオン型の配列 items: Array<A | B> がうまく導出できなかったので、ここだけ自力で型を定義して impl FromJson しました。別にいい感じの書き方はあるかも。

とはいえ「Rust 風」なのは今は derive(FromJson, ToJson) に名残を感じるぐらいで、今は結構 Rust から独立した言語に感じます。

特に trait の impl は Rust 互換であることを完全にやめている感じです。

impl @json.FromJson for Output with from_json(
  json : Json,
  json_path : @json.JsonPath,
) -> Output raise {
  guard json is Array(items) else {
    raise @json.JsonDecodeError((json_path, "Expected an array"))
  }

guard はパターンマッチ構文の特殊系で、 クロージャに items を取り出しつつ、それ以外は else 句でアーリーリターンするのに便利です。

パターンマッチがあるのがとても嬉しいですね。

  let body = req.to_json() |> Json::stringify |> @encoding/utf8.encode

Moonbit 言語仕様の調べ方

このコードを書くにあたって何を調べたか

総論

基本的なパーツは揃ってるので、あとは気合。

Discussion