🚀

JSのデザインパターンライブラリを試作した話と、それをJSRにアップロードしたら無茶苦茶簡単で感動した話

2024/04/30に公開

デザインパターンライブラリを作った

JSRの話だけ読みたい人は読み飛ばしてもOKです。

JavaScriptのtry-catchはC++の影響を受けており、以下の特徴があります。

  • (A) throwは大域脱出的である。
  • (B) try-catchはブロック内の全ての例外副作用に対して一括で作用する。
  • (C) try-catchは文であり、値を返せない。
  • (D) TypeScriptにおいて、例外型は明示されない。

このうち (B), (C), (D) の問題を解決するため、RustのResultや類似のパラダイムをJSに輸入する試みがしばしば行われています。しかしこの解決手段にはいくつかの問題があり、

  • (E) rethrowの専用構文がないためボイラープレートが増える。
  • (F) 出力ストリームに対するwriteなど、戻り値を持たない副作用関数に対するエラーハンドリングが抜け落ちないようにLinterで保護する必要がある。

という点に気をつける必要があると筆者は認識しています。

そこで、かわりに (A) の性質を維持しながら (B), (C) の問題を解決する方法として、以下のような書き味で書けるようなライブラリを作ってみました。

import { Throw, Try } from "jsr:@qnighy/metaflow/exception";

// エラーを握り潰してnullに変換する
const url = Try(() => new URL(input)).done(() => null);

// 特定のエラーのみ握り潰して別の値で置きかえる
const url = Try(() => new URL(input))
  .pick(SyntaxError)
  .done(() => new URL("http://default.example.com"));

// エラーを別のクラスでラップする
const url = Try(() => new URL(input))
  .pick(SyntaxError)
  .done((e) => Throw(new URLError(e)));

ポイントは以下の2点です。

  • JavaScriptの例外機構はそのまま利用する。
  • ただし、エラーハンドリング中のみ内部的に Result<T, E> を生成し、ユーティリティークラスを通して間接的に利用する。
    • ユーティリティークラスは、最終的にエラーをrethrowするか、フォールバック値に変換するかの2通りのどちらかになることを想定して、あえて汎用性を持たせずに作られている。

また、上記のエラーハンドリングライブラリと直接的には関係ないですが、pipeline operatorが恋しいのでメソッドチェーンでラップした代替ライブラリを作りました。

import { Do } from "jsr:@qnighy/metaflow/do";

const result = Do(42)
  .pipe((it) => it + 1) // Equivalent to: |> % + 1
  .pipe((it) => it * 2) // Equivalent to: |> % * 2
  .done();
console.log(result); // => 86

こういうのは車輪の再発明である可能性や結果として微妙なソリューションである可能性も十分にありますが、出さないで放置するくらいなら試しに書いて世に問うてみるのがいいかなと思ってサクッと公開してみました。

詳しくは以下のパッケージ情報を参照してください。

https://jsr.io/@qnighy/metaflow

JSRがとても良かった話

さて上のページは、JSRという新しいパッケージレジストリのサイトです。位置づけとしては、npmが実質的にNode.js用のレジストリであるのに対して、JSRは実質的にDenoを第一利用者として想定したレジストリと位置づけられているとみていいでしょう。ただし、Denoからnpmを使うことも、Node.jsからJSRを使うことも可能ですし、いずれも esm.sh を通じてWebブラウザから利用可能です。

さてこのJSRですが、後発の強みを活かして非常にうまく作られています。まず、レジストリ自体がall-in-oneな構成になっており、特に rubydoc.infodocs.rspkg.go.dev にあたる生成されたドキュメントの閲覧機能が組み込まれているところにはこだわりを感じます。たとえば先ほど紹介した例に出てきた Try 関数のドキュメントは以下の通りです。

https://jsr.io/@qnighy/metaflow@0.1.0/doc/exception/~/Try

ライブラリ開発者視点で最も感動したのは、publishまでのステップ数の短さです。これはDenoのNode.jsに対する優位性も関係しています。DenoはNode.jsがコミュニティーベースで組み立ててきたエコシステムを全部込みで (out of the box) 提供しようとする傾向があり、たとえばフォーマッターやテストハーネスやTypeScriptまわりが組み込まれているためそのあたりの盆栽作業がほぼ不要で、ビルドステップ一切なしでも十分に開発できます。

そして package.json と同様、JSRの設定をDenoの設定と同居させることが可能で、パッケージ設定はきわめてシンプルです。以下は同パッケージをpublishしたときの実際のメタデータです。 (サンプルではなく本物の事例です)

deno.json
{
  "name": "@qnighy/metaflow",
  "version": "0.1.0",
  "exports": {
    "./do": "./do.ts",
    "./exception": "./exception.ts",
    "./tap": "./tap.ts"
  }
}

加えて、CI統合もよく考えられています。RubyGemsなどでの採用例がある、OIDCを利用したTrusted Publishingによるリリースフローを全面的に押し出しています。現時点ではGitHub Actionsのみがサポートされており、あらかじめJSRとGitHubをリンクしておくだけでトークンを用意せずに準備が完了します。実際にpublishするときに用意したCI設定は以下の2ファイルだけでした。リアルにこれだけです。

.github/workflows/ci.yml
name: Build and test

on:
  - push
  - pull_request

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x
      - run: deno test
      - run: deno fmt --check
.github/workflows/publish.yml
name: Publish

on:
  push:
    tags:
      - v*

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x
      - run: deno publish

あとは deno.json のバージョンを変更してタグを打ち、pushすればパッケージが世に公開されるというわけです。

まとめると、JSRにライブラリを公開するには、以下のたった4ステップで足ります。

  1. GitHubのリポジトリを作って、コードを突っ込む
  2. JSR上でパッケージ名を予約し、リポジトリにリンク
  3. ↑のDenoの設定と、GitHub Actionワークフローを入れる
  4. タグを打つ

これなら「とりあえず公開してみよう」の精神で実験作をバンバン公開していくことができますし、うまくいったライブラリを更新するときの手間も減るわけですから最高です。

npmエコシステムとの共存においてプラグマティックな課題や敷居の高さは依然としてあると思いますし、足りない機能もたくさんあるとは感じましたが、それでも初期体験としてかなり完成されているので推していきたいです。

Discussion