世界を動かす技術を、日本語で。

CLIバリデーションを書くのをやめよう。最初から正しく解析しよう

概要

  • CLIツールのバリデーションコードの繰り返し問題
  • 「Parse, don’t validate」の考え方をCLIにも適用
  • Optiqueによる型安全なCLIパーサの実現
  • TypeScriptの型推論活用でバリデーション不要化
  • コードの信頼性・可読性・保守性向上

CLIバリデーションコードの苦痛と解決策

  • CLIツール開発で 同じようなバリデーションコード が乱立する現状
  • オプション依存、排他フラグ、環境別要件など 複雑なパターン の繰り返し出現
  • バリデーション自体は難しくないが、 どのプロジェクトでも冗長 になりがち
  • 他のデータ型(JSON等)では Zod等のパーサで直接型安全に処理 している事実
  • CLIだけが「とりあえずパース→バリデーション地獄」の 旧来方式 に留まる現状

「Parse, don't validate」のCLI適用

  • Alexis Kingの「Parse, don’t validate」ブログに 大きな影響 を受けた経験
  • データを「あとから検証」ではなく「最初から正しい型へパース」 する発想
  • 無効データは パース時点で拒否、余計なif文不要
  • CLIでも 型安全なパース を実現すべきという着想

Optiqueによる型安全なCLIパース

  • Optiqueは CLIバリデーションの繰り返し問題 を根本解決するために開発
  • 典型的なバリデーションパターンを パーサの型定義で吸収
    • 依存オプション :serverがfalseならport存在せず、serverがtrueなら必須
    • 排他オプション :or()で「どれか一つだけ」を型で表現
    • 環境依存オプション :or()で環境ごとに異なる型を指定
  • TypeScriptの型推論 で、正しい組み合わせしか許可されない状態を自動生成
  • ランタイムバリデーション不要化、バグ発生源の大幅削減

パーサコンビネータの直感的応用

  • 「パーサコンビネータ」は 難解そうな名前だが実はシンプル
  • パーサは関数、コンビネータはパーサを組み合わせる関数
  • Haskellのoptparse-applicativeに着想を得て CLIパースに応用
  • モナドや圏論不要、ただの関数合成で完結

TypeScript型推論の恩恵

  • Optiqueでは CLIオプション型を手書き不要
  • パーサ定義だけで TypeScriptが型を自動生成
  • actionが"deploy"ならenvironment必須、"rollback"ならversion必須など 型レベルで厳密化
  • 型安全・自動補完・バグ防止 を実現

Optique導入後の変化

  • バリデーションロジックの大部分を削除 可能
  • CLI引数仕様の変更も パーサ定義修正+型エラー修正で完結
  • 複雑なオプション関係も気軽に追加可能
  • オプショングループの再利用性向上 (merge等で柔軟に合成)
  • 「コンパイルが通れば動く」 という強い信頼感

Optiqueを使うべき人・使わなくていい人

  • 10行スクリプト や単一引数なら不要
  • 以下の経験がある人にはおすすめ
    • バリデーションロジックの同期ずれ
    • 本番での オプション組み合わせ爆発
    • --verboseと--jsonの併用バグ の追跡
    • 依存関係チェックの繰り返し実装
  • Optiqueは若いプロジェクト だが、「parse, don’t validate」の思想は堅牢

まとめ・導入のすすめ

  • Optique公式チュートリアル やGitHubで実際に試すことを推奨
  • CLIのバリデーション地獄からの解放 を目指す開発者向け
  • Optiqueが CLI問題の万能薬とは言わない が、同じバリデーションを何度も書く無駄は減らせる
  • 「そのバリデーションコード、本当に必要?」と 自問自答を促すツール

Hackerたちの意見

考えてみて。APIからJSONを受け取ったら、ただ適当にパースしてif文をたくさん書くわけじゃないよね。Zodみたいなもので、欲しい形に直接パースするんだ。無効なデータ?パーサーがそれを拒否する。これで終わり。コードを書くこととZodを使うことは同じじゃない?違いは誰がコードを書いたかってこと。もちろん、Zodが堅牢で、テストされていて、サポートされていて、拡張可能で、ドキュメントもあって、どうやって自分のドメインを表現するか理解できることを願ってるよね。そして、ZodのAPIが変わるときに、あまり時間をかけずに移行できることも願ってる。

そう、どちらもコードを書くことだよ。でも、ほとんどの場合、表現したい制約はZodで表現できるから、Zodを使うことで書くコードが少なくなって、書いたコードもより正確になるんだ。> もちろん、Zodが堅牢で、テストされていて、サポートされていて、拡張可能で、ドキュメントもあって、どうやって自分のドメインを表現するか理解できることを願ってる。そして、ZodのAPIが変わるときに、あまり時間をかけずに移行できることも願ってる。そう、Zod(または他のライブラリ)に依存する価値を見極めるためには判断が必要だね。これは、TypeScriptやNode、V8、V8がコンパイルされたC++コンパイラ、実行しているx86_64チップ、物理法則に対しても同じ原則だよ。

うん、「パースしろ、バリデートするな」ってアドバイスは、これがあるから空虚に感じる。誰かがそのバリデーションをやってるんだから。アドバイスは「人気のライブラリを再実装しないようにしよう」って言った方がいいかも。

重要なポイントは、著者が明言していないけど、(a) パースが最初に全部行われて、バリデーションとロジックが一緒になっていないこと、(b) パースによってアプリケーションの不変条件をエンコードした新しい構造が作られるから、アプリケーションの他の部分は何もチェックする必要がなくなるってことだと思う。Zodを使うか手動でやるかは関係なくて、大事なのはデータを変換する前処理ステップがあって、単にバリデーションするだけじゃないってことだね。

これは繰り返し出てくるアイデアだね。「パースして、検証しない」。以前の記事: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va... (2019年、Haskellを使用) https://www.lelanthran.com/chap13/content.html (2025年4月、Cを使用)

著者は最初にアレクシス・キングにクレジットを与えていて、その投稿にリンクしてる。

// これはパーサーだ >> const port = option("--port", integer()); これがパーサーだって、どういうこと?型がない言語で型を強制する方法なだけじゃないの?コマンドラインのテキストを受け取って、構文や値を検証するための状態遷移機械みたいなものを期待してたんだけど。

重い作業は optioninteger の定義で行われるんだ。これらは引数を受け取って、何らかの Stream -> Result> 関数を出力する。ちょっとごちゃごちゃに聞こえるかもしれないけど、著者が言ってるようにパーサーコンビネーターはそんなに複雑じゃなくて、慣れるのも早いし、自分でライブラリを作りたいならかなりシンプルだよ。裏で動いてるコードはあまり多くないし、特別な魔法もない。あのパース手法の利点は、かなり宣言的だってこと。これが著者の核心的なポイントみたい。パーサーコンビネーターのコードは、好きなコンビネータライブラリを使って、パース結果として欲しいオブジェクトを書き出すだけで、すべてが自動的に動いて、もし言語にその機能があれば素晴らしい型チェックもある。欠点は、1. どんなパース手法でもそうだけど、実際にパースしたいもののニュアンスを考慮しないといけない(例えば、ホワイトスペースの扱いに関する条件付きルールとか)。ブログ記事からの印象だけど、このプロジェクトは Stream タイプを argv リストとして扱うことでその辺を回避してるように見える。だから「次のブロブを文字列としてパースする」みたいなことができるんだ。2. 手作りのパーサーよりも確実に遅いし(メモリも多く使う)、他の「自動生成された」パースコードよりもその点で劣ることが多い。CLI引数の場合、特に argv を基にしたストリームタイプを選んでるなら、その欠点はほとんど存在しないと思う。ただ、cp みたいなコマンドの argv パースではパフォーマンスが悪くなるかもしれない(もしかしたらそうじゃないかも、-- のような区切り文字からのパース失敗が多い git cp とか?)。オプションと巨大なファイルリストがあるから、引数の指定をあまり気をつけないと指数バックトラッキングの問題が出ることもあるし、手作りのパーサーではそれが明らかになるところが、パーサーコンビネーターでは見えなくなっちゃうかも。

投稿やリンクされたチュートリアルには、無効なオプションを渡したときのユーザー体験についての情報が全然ないね。例を実行してみたけど、NodeやTypeScriptのことを忘れすぎてて、うまくいかなかった。(@optiqueの参照が解決できない。)--foo、--target bar、または--port 3.14を渡したらどうなるの?

私も似たような疑問があるんだけど、「出力形式」や「または」ステートメントは、ユーザーが間違えたことを知らせるのではなく、決定論的に一つの勝者を選ぶように見える。良いパーサーは素晴らしいけど、有用なフィードバックを提供する必要があるよね。

有効なフラグとパラメータの組み合わせごとに関数を書くのが好きなんだ。処理されないものはデフォルトで拒否される。パターンマッチングやガードがあるErlangみたいな言語だと、これがすごく楽だよ。

このアドバイス、いいね。そうそう、いつも違法な状態を表現できないようにしようとしてるけど、ちょっと行き過ぎることもあるかも。ここでの問題は、これをやるときにどうやって良いエラーメッセージを作るかなんだよね。ユーザーが複数の問題を含む入力を渡してきたとき、パーサーが途中でクラッシュしちゃったら、何が間違っているのかリストをどうやって作るの?

ちゃんとしたバリデーションライブラリなら、こういうことに対処するオプションを提供してくれるよね。エラーの配列を含む集約エラーを返してくれたり、特定のバリデーションエラーを読みやすくするためのエラーメッセージ「整形ツール」を書けるようにしてくれたり。

同意。TypeScriptが不適合な型の変数にオブジェクトリテラルを割り当てようとしたときのエラーメッセージと同じくらいのものが得られるべきだと思う。今それが可能かどうか、またそうでない場合にどれくらい難しいかは分からないけど。

ちょっと文字通りに捉えすぎじゃない?「無効な状態を表現できないようにする」っていうのは、主にドメインコードがあるアプリケーションのことを指してると思うよ。入力とは別にね。彼はzodの例を挙げていて、これは彼がパーサーと定義しているバリデーションライブラリなんだ。彼が言いたいのは、「CLIで自分のバリデーションを書きたくないから、まずバリデーションしてから入力を宣言したスキーマに変換してくれる良いAPIをくれ」ってことだね。

UIの話をするなら、ユーザーのデータを傷つけないことが大事だよね。エラーがあっても、バックエンドシステムに渡せなくても、表現可能でなきゃいけない。特にパースに関しては、エラーを超えて進むためのエラー回復に関する文献もあるよ。

彼のor構文を使って、--portなしで--serverを許可することができるかも。でも、その後にデフォルトのerror_messageプロパティも追加してね。パース後にerror_messageが存在するか確認して、そのエラーを出す感じで。

PureScriptではoptparse-applicativeを使えばいいよ。アプリカティブはこれにぴったりだし、ライブラリが無料で提供してくれるから。

Docopt! http://docopt.org/ 使用方法の文字列を仕様にしよう! 使われてないのがもったいないライブラリだよ。

一番好き。ちょっと魔法が多すぎるって人もいるけど、私にはしっかり仕様が決まってるように見える。

Cの構文以外での「宣言は使用に従う」のいい例だね。

「問題」は、いくつかの言語にはCLIオプションをサポートするために必要な制約をすべてエンコードできるほどリッチな型システムがないことだね。そして、多くのプログラマーは自分が持ってる型システムをうまく使えないことが多い。

RustのClapはこれをずっと前に解決したよ。あと、ネイティブバイナリにコンパイルできない言語でCLIプログラムを書くのはやめた方がいいよ。コマンドラインツールを実行するために、あなたのランタイムを持ち歩きたくないから。

自分が好きな言語でCLIプログラムを書き続けるから、ありがとう。これらのプログラムが自分用か内部用か考えたことある?ランタイムはどうせインストールされるって分かってるのに。

clapの宣言的な使い方は、プログラム的なアプローチほど文書化されてないけど、だいたいは理解しやすいよね。clapの好きなところの一つは、--help情報を自動で出力するように設定できるところ。それに、シェルのオートコンプリートも生成できるんだ!今は他にも挑戦してるライブラリがあるみたいだけど(依存関係が少ないとか?)、clapが基準を作ってる感じ。

ほとんどのコマンドラインツールには、システムにインストールしなきゃいけないランタイム依存関係があるよね。$ ldd /usr/bin/rg linux-vdso.so.1 (0x00007fff45dd7000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x000070764e7b1000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x000070764e6ca000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000070764de00000) /lib64/ld-linux-x86-64.so.2 (0x000070764e7e6000) 一番厄介なのは、インストールホストにインストールされているlibcより新しいlibcを使うコンパイラでCプログラムをコンパイルすること。

もうその船は出ちゃったみたいだね。Claude CodeとGemini CLIはNode.jsのインストールが必要で、GeminiのREADMEにはnpmはみんな知ってて、すでにインストールしてるツールみたいに書いてある。 https://www.anthropic.com/claude-code https://github.com/google-gemini/gemini-cli

シェルスクリプト好き?っていうか、同意するけど、明日からシェルスクリプトがなくなったらこの世界はもっと良くなると思う。でも、たぶん君が言いたかったこととは違うよね。

CLIプログラムはネイティブバイナリにコンパイルできない言語で書かない方がいいよ。コマンドラインツールを実行するためにランタイムを持ち歩きたくないから。 それ、ちょっと混乱するな。BASHでたくさんスクリプトを書くのは、いろんなアーキテクチャに簡単に移動できるようにするためなんだ。カスタムランタイムも必要ないし。解釈されたスクリプトは、人間が読めたり編集できたりする利点もあるしね。

それに、CLIプログラムはネイティブバイナリにコンパイルできない言語で書かない方がいいよ。コマンドラインツールを実行するためにランタイムを持ち歩きたくないから。Goプログラムはネイティブ実行ファイルにコンパイルされるけど、特に--helpを表示したいだけの時は、起動が遅いんだよね。

それに、CLIプログラムはネイティブバイナリにコンパイルできない言語で書かない方がいいよ。コマンドラインツールを実行するためにランタイムを持ち歩きたくないから。CMakeやランダムなtarballに依存する言語でプログラムを書くのもやめてほしい。ビルドに苦労するより、ランタイムを持ち歩く方が問題が少ないことが多いから。

これ、すごく変な立場だね。誰が人にどの言語を使うべきか決める権利があるの?特にCLIに関しては?

記事にめっちゃ同意!これが、私がRadを作った理由の一つなんだ。ここにいる人たちも興味を持つかもしれないよ。アイデアは、CLIスクリプトを宣言的なアプローチで書くこと。引数の制約を全部含めて、関係性のあるものもね。だから、自分でCLIのバリデーションを書く必要がないんだ。引数がどんな形を取るべきかを宣言して、Radにユーザー入力をチェックさせることで、スクリプトの面白い部分に集中できるんだ。例えば、こんな感じで書くよ:

args: username str # 必須の文字列 password str? # 任意の文字列 token str? # 任意の認証トークン age int # 必須の整数 status str # 必須の文字列

username requires password // ユーザー名が提供されたら、パスワードも必要 token excludes password // トークンとパスワードは一緒に使えない age range [18, 99] // 18歳から99歳までの範囲 status enum ["active", "inactive", "pending"]

Radがバリデーションを全部やってくれるから、宣言した制約が満たされている前提で、残りのスクリプトを書くだけでいいんだ。