Optique: TypeScript向けの型安全なCLIパーサー
(optique.dev)こんにちは! TypeScriptでCLIツールをよく作っているうちに、既存ライブラリの限界が物足りなく感じられ、新しいCLIパーサーを作ることになりました。興味のある方に紹介したくて投稿します。
CLIアプリケーションを開発しながら、いつも不便に感じていたことが1つありました。既存のCLIパーサーライブラリの多くは、設定オブジェクトや命令型APIでCLI構造を定義しますが、このやり方では型安全性はもちろん、複雑なCLI構造を表現することも難しいです。
特に相互排他的な(mutually exclusive)オプショングループを表現しようとすると、別途検証ロジックをあちこちに散らばせなければなりませんでした。「このオプションとあのオプションは同時に使えない」「このモードではこのようなオプションだけが許可される」といった制約を、コードで綺麗に表現するのが難しかったのです。そしてTypeScriptを使っていても、パース結果の型を手動で定義しなければならない場合が多くありました。
関数型パーサーコンビネーター(parser combinator)というアプローチ
そこでHaskellのoptparse-applicativeに着想を得て、関数型パーサーコンビネーター方式のTypeScript CLIパーサーを作ってみました。
従来の方式:
// 既存ライブラリの典型的な方式
const program = new Command()
.option('-p, --port <number>', 'port number')
.option('-h, --host <string>', 'hostname')
.action((options) => {
// optionsの型はany、または手動で定義する必要がある
});
Optique方式:
// 小さなパーサーを組み合わせて大きな構造を作る
const serverConfig = object({
port: option("-p", "--port", integer({ min: 1, max: 65535 })),
host: option("-h", "--host", string()),
verbose: option("-v", "--verbose")
});
// TypeScriptが自動で型を推論!
// { port: number, host: string, verbose: boolean }
const config = run(serverConfig);
特徴1: 相互排他的オプションを構造で表現
最大の特徴は、相互排他的なオプショングループを自然に表現できる点です。既存ライブラリではこのような制約を別途検証ロジックで処理しなければなりませんでしたが、Optiqueではor()コンビネーターによって、構造そのものに制約を埋め込めます。
// サーバーモード vs クライアントモード - 完全に異なるオプションセット
const parser = or(
object({
mode: constant("server"),
port: option("-p", "--port", integer()),
workers: option("-w", "--workers", integer()),
ssl: option("--ssl")
}),
object({
mode: constant("client"),
connect: option("-c", "--connect", string()),
timeout: option("-t", "--timeout", integer()),
retries: option("--retries", integer())
})
);
// TypeScriptが自動でdiscriminated unionを生成
// { mode: "server", port: number, workers: number, ssl: boolean } |
// { mode: "client", connect: string, timeout: number, retries: number }
既存ライブラリであれば、このような検証を手動で行う必要があったでしょう:
// 従来方式の煩雑さ
if (options.mode === "server" && options.connect) {
throw new Error("--connectはサーバーモードでは使用できません");
}
if (options.mode === "client" && options.workers) {
throw new Error("--workersはクライアントモードでは使用できません");
}
特徴2: 完全自動の型推論
const gitLike = or(
command("add", object({
type: constant("add"),
files: multiple(argument(string())),
all: option("-A", "--all")
})),
command("commit", object({
type: constant("commit"),
message: option("-m", "--message", string()),
amend: option("--amend")
}))
);
// 結果はdiscriminated unionとして自動推論される
const result = run(gitLike);
if (result.type === "add") {
// TypeScriptが自動で型を絞り込んでくれる
console.log(`Adding ${result.files.join(", ")}`);
}
特徴3: モジュール化と再利用性
merge()コンビネーターでオプショングループを再利用できるため、複数のコマンドで共通オプションを簡単に共有できます。
// 再利用可能なオプショングループを定義
const networkOptions = object({
host: option("--host", string()),
port: option("--port", integer())
});
const authOptions = object({
username: option("-u", "--user", string()),
password: optional(option("-p", "--password", string()))
});
// 必要に応じて組み合わせる
const devMode = merge(networkOptions, object({ debug: option("--debug") }));
const prodMode = merge(networkOptions, authOptions, loggingOptions);
特徴4: 豊富な組み込みバリデーション
値パーサーは単純な型変換を超えて、意味のある検証を提供します。
const parser = object({
// ファイルシステム上で実在するかを検査
inputFile: option("--input", path({ mustExist: true })),
// ポート番号の範囲を検証
port: option("-p", "--port", integer({ min: 1, max: 65535 })),
// URLプロトコルを制限
api: option("--api", url({ allowedProtocols: ["https:"] })),
// 選択肢を制限
logLevel: option("--log", choice(["debug", "info", "warn", "error"]))
});
ランタイム対応
- @optique/core: すべてのJavaScriptランタイムに対応 (ブラウザ、エッジ関数など)
- @optique/run: Node.js、Bun、Deno向けのバッテリー同梱版
インストール:
deno add --jsr @optique/core @optique/run
npm add @optique/core @optique/run
pnpm add @optique/core @optique/run
yarn add @optique/core @optique/run
bun add @optique/core @optique/run
終わりに
既存のCLIライブラリが「設定を通じてパーサーを作る」方式だとすれば、Optiqueは「小さなパーサーを組み合わせて大きなパーサーを作る」関数型アプローチです。
特に相互排他的なオプショングループを表現する際に、この違いははっきり表れます。複雑なCLIの制約事項を別途検証ロジックなしでパーサー構造そのもので表現できるため、型安全性とコードの簡潔さを同時に得られます。
もちろんまだ初期開発段階のためAPIが変更される可能性はありますが、関数型パーサーコンビネーターのエレガンスをTypeScript CLI開発に持ち込みたい方なら、一度試してみる価値があると思います。
1件のコメント
わあ、いいですね!共有ありがとうございます。私もCLIを作るときに使ってみようと思います。