🌈

CLIのオプション分岐、もうif文は書かなくていい

に公開

CLIツールを作ったことがある方なら、一度はこんなコードを書いた経験があるのではないでしょうか:

if (opts.reporter === "junit" && !opts.outputFile) {
  throw new Error("--output-file is required for junit reporter");
}
if (opts.reporter === "html" && !opts.outputFile) {
  throw new Error("--output-file is required for html reporter");
}
if (opts.reporter === "console" && opts.outputFile) {
  console.warn("--output-file is ignored for console reporter");
}

以前、「TypeScriptの型推論でCLIバリデーションをなくせた話」という記事で、個々のオプション値を正しくパースする方法について書きました。しかし、オプション間の関係については触れていませんでした。

上のコードでは、--output-file--reporterjunithtmlのときだけ意味があります。consoleのときは、そもそもこのオプションは存在すべきではありません。

TypeScriptを使っています。強力な型システムがあります。なのに、コンパイラが助けてくれないランタイムチェックを書いています。新しいreporterタイプを追加するたびに、これらのチェックを更新し忘れないようにしなければなりません。リファクタリングするたびに、見落としがないことを祈るしかありません。

TypeScript CLIパーサーの現状

Commander、yargs、minimistといった従来の定番ライブラリは、TypeScriptが主流になる前に作られました。型のない文字列の集合を返すだけで、型安全性は開発者任せです。

しかし進歩はあります。cmd-tsClipanion(Yarn Berryで使われているライブラリ)のようなTypeScriptファーストのライブラリは、型を真剣に扱っています:

// cmd-ts
const app = command({
  args: {
    reporter: option({ type: string, long: 'reporter' }),
    outputFile: option({ type: string, long: 'output-file' }),
  },
  handler: (args) => {
    // args.reporter: string
    // args.outputFile: string
  },
});
// Clipanion
class TestCommand extends Command {
  reporter = Option.String('--reporter');
  outputFile = Option.String('--output-file');
}

これらのライブラリは個々のオプションの型を推論してくれます。--portnumber--verboseboolean。確かに進歩です。

しかし、これらにできないことがあります:--reporterjunitのとき--output-file必須で、consoleのとき禁止という関係を表現することです。オプション間の関係は型システムに反映されません。

結局、バリデーションコードを書くことになります:

handler: (args) => {
  // cmd-tsでもClipanionでもこれが必要
  if (args.reporter === "junit" && !args.outputFile) {
    throw new Error("--output-file required for junit");
  }
  // args.outputFileは依然としてstring | undefined
  // reporterが"junit"のときは確実にstringだと、TypeScriptは知らない
}

RustのclapやPythonのClickにはrequiresconflicts_with属性がありますが、それらもランタイムチェックです。結果の型は変わりません。

パーサーの設定がオプション間の関係を知っているなら、なぜその知識が結果の型に現れないのでしょうか?

conditional()で関係をモデリングする

Optiqueは、オプション間の関係を第一級の概念として扱います。テストレポーターのシナリオはこうなります:

import { conditional, object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { choice, string } from "@optique/core/valueparser";
import { run } from "@optique/run";

const parser = conditional(
  option("--reporter", choice(["console", "junit", "html"])),
  {
    console: object({}),
    junit: object({
      outputFile: option("--output-file", string()),
    }),
    html: object({
      outputFile: option("--output-file", string()),
      openBrowser: option("--open-browser"),
    }),
  }
);

const [reporter, config] = run(parser);

conditional()コンビネータは、判別用のオプション(--reporter)とブランチのマップを受け取ります。各ブランチは、その判別値に対して有効な他のオプションを定義します。

TypeScriptは結果の型を自動的に推論します:

type Result =
  | ["console", {}]
  | ["junit", { outputFile: string }]
  | ["html", { outputFile: string; openBrowser: boolean }];

reporter"junit"のとき、outputFilestringです。string | undefinedではありません。関係が型に埋め込まれています。

ビジネスロジックで本当の型安全性が得られます:

const [reporter, config] = run(parser);

switch (reporter) {
  case "console":
    runWithConsoleOutput();
    break;
  case "junit":
    // TypeScriptはconfig.outputFileがstringだと知っている
    writeJUnitReport(config.outputFile);
    break;
  case "html":
    // TypeScriptはconfig.outputFileとconfig.openBrowserが存在すると知っている
    writeHtmlReport(config.outputFile);
    if (config.openBrowser) openInBrowser(config.outputFile);
    break;
}

バリデーションコードなし。ランタイムチェックなし。新しいreporterタイプを追加してswitchで処理し忘れたら、コンパイラが教えてくれます。

より複雑な例:データベース接続

テストレポーターはわかりやすい例ですが、もっとバリエーションのあるものを試してみましょう。データベース接続文字列です:

myapp --db=sqlite --file=./data.db
myapp --db=postgres --host=localhost --port=5432 --user=admin
myapp --db=mysql --host=localhost --port=3306 --user=root --ssl

データベースの種類ごとに必要なオプションが全く異なります:

  • SQLiteはファイルパスだけが必要
  • PostgreSQLはhost、port、user、オプションでpasswordが必要
  • MySQLはhost、port、userが必要で、SSLフラグがある

これをモデリングするとこうなります:

import { conditional, object } from "@optique/core/constructs";
import { withDefault, optional } from "@optique/core/modifiers";
import { option } from "@optique/core/primitives";
import { choice, string, integer } from "@optique/core/valueparser";

const dbParser = conditional(
  option("--db", choice(["sqlite", "postgres", "mysql"])),
  {
    sqlite: object({
      file: option("--file", string()),
    }),
    postgres: object({
      host: option("--host", string()),
      port: withDefault(option("--port", integer()), 5432),
      user: option("--user", string()),
      password: optional(option("--password", string())),
    }),
    mysql: object({
      host: option("--host", string()),
      port: withDefault(option("--port", integer()), 3306),
      user: option("--user", string()),
      ssl: option("--ssl"),
    }),
  }
);

推論される型はこうなります:

type DbConfig =
  | ["sqlite", { file: string }]
  | ["postgres", { host: string; port: number; user: string; password?: string }]
  | ["mysql", { host: string; port: number; user: string; ssl: boolean }];

細部に注目してください:PostgreSQLはデフォルトでポート5432、MySQLは3306。PostgreSQLはオプションのpasswordがあり、MySQLはSSLフラグがあります。各データベースタイプは必要なオプションだけを持ちます。過不足なく。

この構造では、modeがsqliteのときにdbConfig.sslを書こうとしても、ランタイムエラーではなくコンパイル時に不可能だとわかります。

requires_if属性でこれを表現してみてください。できません。関係が豊かすぎます。

このパターンはどこにでもある

一度気づくと、多くのCLIツールでこのパターンが見つかります:

認証モード:

const authParser = conditional(
  option("--auth", choice(["none", "basic", "token", "oauth"])),
  {
    none: object({}),
    basic: object({
      username: option("--username", string()),
      password: option("--password", string()),
    }),
    token: object({
      token: option("--token", string()),
    }),
    oauth: object({
      clientId: option("--client-id", string()),
      clientSecret: option("--client-secret", string()),
      tokenUrl: option("--token-url", url()),
    }),
  }
);

デプロイターゲット出力フォーマット接続プロトコル——モードセレクタが他のオプションの有効性を決定する場所ならどこでも使えます。

なぜconditional()が存在するのか

Optiqueには既に相互排他的な選択肢のためのor()コンビネータがあります。なぜconditional()が必要なのでしょうか?

or()コンビネータは構造——どのオプションが存在するか——に基づいてブランチを区別します。git commitgit pushのように引数が完全に異なるサブコマンドには適しています。

しかしreporterの例では、構造は同一です:どのブランチも--reporterフラグを持ちます。違いはフラグのにあり、存在の有無ではありません。

// これは意図通りに動かない
const parser = or(
  object({ reporter: option("--reporter", choice(["console"])) }),
  object({ 
    reporter: option("--reporter", choice(["junit", "html"])),
    outputFile: option("--output-file", string())
  }),
);

--reporter junitを渡すと、or()はどのオプションが存在するかに基づいてブランチを選ぼうとします。両方のブランチが--reporterを持っているので、構造的に区別できません。

conditional()は判別対象のオプションの値を先に読み、それから適切なブランチを選ぶことでこの問題を解決します。構造的なパースと値に基づく判断のギャップを埋めます。

構造が制約である

オプションを緩い型にパースしてから関係をバリデートするのではなく、構造そのものが制約であるパーサーを定義します。

従来のアプローチ Optiqueのアプローチ
パース → バリデート → 使用 パース(制約付き) → 使用
型とバリデーションロジックを別々に管理 型が制約を反映
不整合はランタイムで発見 不整合はコンパイル時に発見

パーサーの定義が唯一の信頼できる情報源になります。新しいreporterタイプを追加したら、パーサーの定義が変わり、推論される型が変わり、コンパイラが更新が必要な箇所をすべて教えてくれます。

試してみる

開発中のCLIにこのパターンが当てはまりそうなら:

次にオプション間の関係をチェックするif文を書こうとしたら、一度立ち止まって考えてみてください:この制約をパーサーで表現できないだろうか?

パーサーの構造が制約である。 そのバリデーションコード、実はもう必要ないかもしれません。

Discussion