npm Library

Node.js Testing genType

genType 経由でファーストクラスの TypeScript バインディングを備えた、ReScript で記述された 公開可能な npm パッケージ。テンプレートは小規模ながら現実的な API 表面を同梱している — 同期的な挨拶、非同期の fetchWithTimeout(AbortController)、2 つのリストヘルパー —ので、初日からコンパイル・テスト・公開できる素材が揃っている。

プロジェクトを npm(パブリック/プライベートレジストリ)にリリースする予定で、JS/TS 利用者に ReScript ソースから 生成された本物の .d.ts 型を提供したい場合に選択する。本テンプレートは ESM のみの利用者を想定している。CJS 相互運用が必要であれば後から重ねて対応できるが、デフォルトはモダンバンドラと Node 24+ をターゲットにしている。

生成内容

my-project/
├── rescript.json
├── package.json                   # ESM, "main" / "types" / "exports" / "files" set
├── tsconfig.json                  # strict, declaration: true (for genType .d.ts)
├── src/
│   ├── Index.res                  # public entry — re-exports + greetChecked
│   ├── ListUtils.res              # chunk + partitionMap (pure helpers)
│   ├── Fetcher.res                # fetchWithTimeout (AbortController + setTimeout)
│   ├── Validation.res             # zod or sury — selected in the wizard
│   └── __tests__/
│       ├── Index.test.mjs         # greet smoke test (imports .res.mjs)
│       ├── ListUtils.test.mjs     # chunk + partitionMap suites
│       └── Fetcher.test.mjs       # fetchWithTimeout with vi.stubGlobal("fetch")
├── README.md                      # script docs + API Surface table + Publish flow
├── LICENSE                        # MIT, holder = project name
├── .nvmrc                         # Node 24
├── .gitignore                     # node_modules + ReScript build artifacts
├── .editorconfig                  # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml             # weekly npm updates
    └── workflows/ci.yml           # install + rescript build + vitest

ウィザードオプション

オプション

効果

Project name

npm の name、LICENSE 保有者、および Index.res のデフォルト挨拶のサフィックスとなる

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README のインストールコマンド、CI のキャッシュキーに影響する

Validation library

zodsury。同梱される src/Validation.res のバリアントと追加される依存を選択する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル後の .res.mjs がランタイムでインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

zod または sury

バリデーションのバックエンド(ウィザードで選択)

TemplateVersions.ZOD / TemplateVersions.SURY

vitest (dev)

モジュールごとのテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 用のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

typescript (dev)

tsc --noEmit および genType による .d.ts チェックに必要

TemplateVersions.TYPESCRIPT

主要なファイル

src/Index.res

公開エントリポイント。パッケージが提供するすべてのエクスポートは、定義として、あるいは別モジュールからの再エクスポートとして ここに集約されている。それぞれに @genType が付与されており、genType が Index.gen.d.ts に対応する宣言を生成する。

/** Returns a greeting addressed to [name]. */
@genType
let greet = (name: string) => {
  `Hello from <projectName>, ${name}!`
}

/**
 * Validates an untyped JSON input before greeting. Returns an [Error] message
 * for TS/JS consumers that pass a malformed payload instead of throwing.
 */
@genType
let greetChecked = (input: JSON.t) =>
  switch Validation.parseGreetInput(input) {
  | Ok({name}) => Ok(greet(name))
  | Error(message) => Error(message)
  }

@genType let chunk = ListUtils.chunk
@genType let fetchWithTimeout = Fetcher.fetchWithTimeout

greetChecked は「呼び出し側を信用しない」場合の定番パターンである: JSON を Validation.parseGreetInput に通し、FFI 境界をまたぐ例外を投げる代わりに result を返す。型チェッカが不正な引数を捕捉できない素の JavaScript から利用しても安全な理由はここにある。

src/ListUtils.res

Array 操作の実例を兼ねた 2 つの汎用ヘルパー:

  • chunk(xs, ~size) — 配列を固定サイズのバケットに分割する。末尾のバケットは短くなる場合がある。

  • partitionMap(xs, f) — 各要素に f を適用し、Ok / Error の結果を 2 つの配列のタプルに振り分ける。

いずれも可変な ref アキュムレータで実装されており、読みやすく、実行時も高速で、ReScript の標準ライブラリが関数型と命令型のスタイルを組み合わせて使える点を示す好例である。

src/Fetcher.res

fetchWithTimeout は ReScript における Promise + AbortController の定番パターンを示している:

@val external fetch: (string, 'opts) => promise<'response> = "fetch"

type abortController
@new external makeAbortController: unit => abortController = "AbortController"
@get external signal: abortController => 'signal = "signal"
@send external abort: abortController => unit = "abort"

@genType
let fetchWithTimeout = async (url: string, ~timeoutMs: int): promise<'response> => {
  let controller = makeAbortController()
  let timer = setTimeout(() => controller->abort, timeoutMs)
  try {
    let response = await fetch(url, {"signal": controller->signal})
    clearTimeout(timer)
    response
  } catch {
  | err =>
    clearTimeout(timer)
    raise(err)
  }
}

clearTimeout の呼び出しは成功・失敗の 両方 の分岐に置かれており、タイマーがリークしないようになっている。中断可能な非同期ヘルパーを書くときはこの形を踏襲すること — ストリーミング、リトライ、キャンセル トークンに自然に拡張できる。

src/Validation.res

parseGreetInput: JSON.t => result<greetInput, string> は、JS/TS 利用者が渡す 公開 API 引数 をバリデートする。シグネチャは zod 版と sury 版で同一なので、呼び出し側と型はどちらの バックエンドが選ばれたかで分岐する必要がない。

型なし入力を受ける新しい公開関数を追加する場合、ここに新しい parseFooInput を定義し、Index.res で実処理を行う前に呼び出すこと。result を返すことで契約が誠実に保たれる: JS 側は値か 可読なエラーメッセージを受け取り、FFI 境界をまたいで例外が伝搬することはない。

src/__tests__/Index.test.mjs

スモークテストは コンパイル後Index.res.mjs をインポートし、エクスポートされた greet を検証する:

import { describe, expect, it } from "vitest";
import { greet } from "../Index.res.mjs";

describe("greet", () => {
  it("returns a greeting containing the supplied name", () => {
    expect(greet("world")).toContain("world");
  });
});

これは公開 API が JS 利用者の視点から到達可能であることを保証する定番のガードである — greet がエクスポートされなくなったり、@genType が外れたりすると、スイートが失敗する。新しい公開関数を追加する際は同じパターンに従うこと: Index.res.mjs からインポートし、実際に呼び出し、ランタイム値を検証する。

src/__tests__/ListUtils.test.mjs

ヘルパー向けのモジュール単位の Vitest スイート。注目すべき点として、ReScript が生成するバリアントのランタイムタグを直接検証している:

const [oks, errs] = partitionMap([1, 2, 3], (n) =>
  n % 2 === 0 ? { TAG: "Ok", _0: n * 10 } : { TAG: "Error", _0: `odd: ${n}` }
);

{ TAG: "Ok", _0: ... } は ESM モードにおいて ReScript がポリモーフィックバリアントに対して 出力する形である。ReScript が生成したユニオンに対して JS 側のテストを書くときはこの点に留意すること — コンストラクタ関数ではなく、生のバリアントペイロードを渡して関数を呼ぶことになる。

src/__tests__/Fetcher.test.mjs

Vitest の vi.stubGlobal でグローバルな fetch をスタブする非同期テスト:

import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWithTimeout } from "../Fetcher.res.mjs";

afterEach(() => vi.restoreAllMocks());

describe("fetchWithTimeout", () => {
  it("returns the response when fetch resolves in time", async () => {
    const fake = { ok: true };
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(fake));
    await expect(fetchWithTimeout("https://example.test", 100)).resolves.toBe(fake);
  });
});

afterEach(() => vi.restoreAllMocks()) は必須である — これがないと、あるテストでスタブした fetch が次のテストへと漏れ、同様にスタブしていないスイートを壊してしまう。新しい fetch ベースのモジュールを 追加する際は同じパターンに従うこと。

package.json のエントリポイント

公開関連のフィールドは、利用者が import { greet } from "<pkg>" と書くだけで本物の型を得られるよう 配線されている:

{
  "type": "module",
  "main": "./src/Index.res.mjs",
  "types": "./src/Index.gen.d.ts",
  "exports": {
    ".": {
      "types": "./src/Index.gen.d.ts",
      "import": "./src/Index.res.mjs"
    }
  },
  "files": [
    "src/**/*.res.mjs",
    "src/**/*.res",
    "src/**/*.gen.d.ts",
    "src/**/*.gen.tsx"
  ]
}

files の許可リストは意図的に絞られている: コンパイル済みの JS、対応する .gen.d.ts 型、(React コンポーネントバインディング用の)任意の .gen.tsx、元の .res ソースのみが公開 tarball に含まれる。それ以外のテスト・設定・node_modules などはデフォルトで除外される。

prepare スクリプト

scripts.prepare = "rescript" を設定しているため、別プロジェクトで本ライブラリを新規に npm install した際に、パッケージが利用される前に ReScript ソースがコンパイルされる。上記の files 許可リストと組み合わせれば、利用者は自分で rescript をインストールする必要がない — import してそのまま使えばよい。

npm スクリプト

スクリプト

説明

build

rescript — ワンショットコンパイル

clean

rescript clean — 生成された .res.mjs を削除

test

vitest run — モジュール単位のスイートを実行

test:coverage

vitest run --coverage — 同上に v8 のカバレッジレポートを付与

prepare

rescript — 公開 tarball の npm install 時に実行される

res:build

rescript — 他テンプレートとの整合性のために残されたエイリアス

res:clean

rescript clean — 他テンプレートとの整合性のために残されたエイリアス

res:dev

rescript -w — 保存時に再コンパイル

preparebuild は現在いずれも rescript だが、別々に保たれている。これにより、npm が依存する prepare の契約を壊さずに、後から build をより広いパイプライン(バンドリング、minify、アセットコピーなど)に つなげられる。

公開フロー

README には Publish セクションに対応する 4 ステップのレシピが同梱されている:

  1. package.json のバージョンをバンプする。

  2. pnpm build(または利用しているパッケージマネージャの同等コマンド)を実行して、files 許可リストが依存する .res.mjs.gen.d.ts のアーティファクトを再生成する。

  3. pnpm test を実行して Vitest が通ることを確認する。

  4. npm publish --access public を実行する(プライベートなスコープ付きパッケージの場合は --access public を外す)。

prepare スクリプトがあるため、利用者が tarball をインストールすると 同時に rescript も実行される — これにより、公開アーティファクトが古くても利用者の node_modules には新鮮なコンパイル結果が入る。これはあくまで セーフティネットとして扱い、npm publish 前に pnpm build を実行することの代替とはしないこと。

npm のスコープ(@your-org/my-lib)下で公開する場合は、package.jsonname と README 冒頭のインストールスニペットを更新すること。bin フィールドは本テンプレートでは利用していない(ライブラリは通常バイナリを 配布しない)ため、ここで改名するものはない。

2日目以降のレシピ

  • TypeScript からの変換 — 既存の .ts モジュールを、他のライブラリ ファイルと並ぶ .res モジュールに移植する

  • デッドコードの検出reanalyze を実行して、公開前にすべての エクスポートが実際に到達可能であることを確認する

  • Import の最適化 — 公開面が広がっても Index.res を整然と保つ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照すること。

補足

  • node エンジンは >=24 に固定されており、.nvmrc には 24 が記載されている。これより古いメジャーバージョンは CI で検証されない。

  • Vitest のスイートは .res ソースではなく、コンパイル後.res.mjs 出力をインポートする。CI は vitest run の前に rescript を実行してアーティファクトが存在することを保証する。ローカルでは別ターミナルで pnpm res:dev を実行しておけば、保存時に再コンパイルされ、Vitest のウォッチャがその変更を拾う。

  • Fetcher.test.mjsvi.stubGlobal でグローバルな fetch をスタブし、afterEach(() => vi.restoreAllMocks()) を使用する。新しい fetch ベースのモジュールを追加する際は 同じパターンに従い、スイートが互いに干渉しないようにすること。

  • ListUtils.test.mjs は ReScript のバリアントランタイムタグ({ TAG: "Ok", _0: ... })を 直接検証する。これが ReScript がポリモーフィックバリアントに対して出力する形である — ReScript 生成のユニオンに対して JS 側のテストを書くときはこの点に留意すること。

  • tsconfig.jsonstrict: truedeclaration: true を有効にして同梱している。これにより genType の .d.ts 出力が tsc --noEmit を通過する。安易にこれらのフラグを緩めないこと — 公開される .d.ts ファイルは TS 利用者が目にする唯一の契約である。

  • prepare スクリプトがあるため、未公開 の tarball(npm pack && npm install ./pkg.tgz)を インストールした利用者にも新鮮な ReScript コンパイルが行われる。node_modules がない CI マシンでは 驚かされることがある。引っかかった場合は --ignore-scripts で上書きすること。

  • .github/dependabot.ymlpackage-ecosystem: npm に対して毎週 PR を作成する。ReScript / @rescript/runtime のバンプは一緒にレビューすること — TemplateVersions.kt で 同じメジャーに固定されているのには理由がある。