Optique: CLI パーサーコンビネーター

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
最近、Optiqueという実験的なCLIパーサーライブラリを作成しました。この記事を書いている2025年8月21日の時点ではまだ0.1.0もリリースされていませんが、面白いアイデアだと思いますので、この記事で紹介します。
Optiqueは主に2つの異なるライブラリから影響を受けています。1つはHaskellのoptparse-applicativeというライブラリで、このライブラリから得た教訓は、CLIパーサーもパーサーコンビネーターになり得るということ、そしてそのように作ると非常に便利だということです。もう1つはTypeScriptユーザーにはすでに馴染みのあるZodです。optparse-applicativeからアイデアの幹を得たものの、HaskellとTypeScriptはあまりにも異なる言語なので、APIの構成方法に大きな違いがあります。そのため、APIの構成方法については、Zodをはじめとする様々なバリデーションライブラリを参考にしました。
Optiqueは、小さなパーサーとパーサーコンビネーターをレゴのパーツのように組み合わせて、CLIがどのような形であるべきかを表現します。例えば、最も小さなパーツの1つとしてoption()
があります:
const parser = option("-a", "--allow", url());
このパーサーを実行するには、run()
というAPIを使用します:(なお、run()
関数は暗黙的にprocess.argv.slice(2)
を読み取ります。)
const allow: URL = run(parser);
上記のコードでは、わざわざURL
という型を明示していますが、特にそうしなくても自動的にURL
型として推論されます。上記のパーサーは-a
/--allow=URL
オプションのみを受け付けます。他のオプションや引数を与えるとエラーになります。-a
/--allow=URL
オプションが与えられなくてもエラーになります。
もし-a
/--allow=URL
オプションを必須ではなく任意にしたい場合はどうすればよいでしょうか?そのような場合は、optional()
コンビネーターでoption()
パーサーをラップします。
const parser = optional(option("-a", "--allow", url()));
このパーサーを実行すると、結果としてどのような型が得られるでしょうか?
const allow: URL | undefined = run(parser);
はい、URL | undefined
型になります。
あるいは、-a
/--allow=URL
オプションを複数受け付けるようにしてみましょう。以下のように書けるようにします:
prog -a https://example.com/ -a https://hackers.pub/
このようにオプションを複数回使えるようにするには、optional()
コンビネーターの代わりにmultiple()
コンビネーターを使います:
const parser = multiple(option("-a", "--allow", url()));
そろそろ結果の型がどうなるか予想できますよね?
const allowList: readonly URL[] = run(parser);
はい、readonly URL[]
型になります。
しかし、もし-a
/--allow=URL
オプションと一緒に使えない、相互排他的な-d
/--disallow=URL
というオプションを追加したい場合はどうすればよいでしょうか?両方のオプションのうち、一方だけを同時に使えるようにする必要があります。このような場合は、or()
コンビネーターを使用します:
const parser = or(
multiple(option("-a", "--allow", url())),
multiple(option("-d", "--disallow", url())),
);
このパーサーは次のようなコマンドをうまく受け付けます:
prog -a https://example.com/ --allow https://hackers.pub/
prog -d https://example.com/ --disallow https://hackers.pub/
しかし、次のように-a
/--allow=URL
オプションと-d
/--disallow=URL
オプションが混在している場合はエラーになります:
prog -a https://example.com/ --disallow https://hackers.pub/
さて、このパーサーの結果はどのような型になるでしょうか?
const result: readonly URL[] = run(parser);
おっと、or()
コンビネーターがラップする2つのパーサーがどちらもreadonly URL[]
型の値を生成するため、readonly URL[] | readonly URL[]
型となり、結果的にreadonly URL[]
型になってしまいました。適切な判別可能なユニオン(discriminated union)形式に変更したいですね。以下のような型が良いでしょう:
const Result =
| { mode: "allowList"; allowList: readonly URL[] }
| { mode: "blockList"; blockList: readonly URL[] };
このようなオブジェクト形式の構造を作りたい場合は、object()
コンビネーターを使用します:
const parser = or(
object({
mode: constant("allowList"),
allowList: multiple(option("-a", "--allow", url())),
}),
object({
mode: constant("blockList"),
blockList: multiple(option("-d", "--disallow", url())),
}),
);
判別子(discriminator)を付与するためにconstant()
パーサーも使用しました。このパーサーは少し特殊で、何も読み取らず、与えられた値を生成するだけです。つまり、常に成功するパーサーです。このように判別可能なユニオンを構成する際によく使われますが、他の創造的な方法でも使用できるでしょう。
これで、このパーサーは私たちが望む型の結果値を生成します:
const result:
| { readonly mode: "allowList"; readonly allowList: readonly URL[] }
| { readonly mode: "blockList"; readonly blockList: readonly URL[] }
= run(parser);
or()
コンビネーターやobject()
コンビネーターは、相互排他的なオプションだけでなく、サブコマンドも同じ原理で実装できます。1つのコマンドにマッチするcommand()
パーサーと、順序引数にマッチするargument()
パーサーを紹介します:
const parser = command(
"download",
object({
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
)
上記のパーサーは以下のようなコマンドにマッチします:
prog download --target=out/ https://example.com/ https://example.net/
パーサーの結果型は次のようになります:
const result: {
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
} = run(parser);
ここにupload
サブコマンドを追加するにはどうすればよいでしょうか?そうです、or()
コンビネーターで組み合わせます:
const parser = or(
command(
"download",
object({
action: constant("download"),
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
),
command(
"upload",
object({
action: constant("upload"),
url: option("-d", "--dest", "--destination", url()),
files: multiple(
argument(file({ metavar: "FILE", type: "file" })),
{ min: 1 },
),
})
),
);
上記のパーサーは、次のようなコマンドを受け付けるようになりました:
prog upload ./a.txt ./b.txt -d https://example.com/
prog download -t ./out/ https://example.com/ https://hackers.pub/
このパーサーの結果型は次のようになります:
const result:
| {
readonly action: "download";
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
}
| {
readonly action: "upload";
readonly url: URL;
readonly files: readonly string[];
}
= run(parser);
同じ方法を応用すれば、ネストされたサブコマンド(nested subcommands)も実装できますよね?
さて、このようにOptiqueがCLIを表現する方法を紹介しましたが、どう思いますか?Optiqueの方法が複雑なCLIを表現するのに適していることが伝わりましたか?
もちろん、Optiqueの方法も完璧ではありません。非常に典型的でシンプルなCLIを定義するには、むしろ手間がかかることも事実です。また、Optiqueは純粋にCLIパーサーの役割だけを果たしているため、一般的なCLIアプリケーションフレームワークが提供する様々な機能は提供していません(今後Optiqueにより多くの機能を追加する予定ではありますが...)。