🌈

TypeScriptの型推論でCLIバリデーションをなくせた話

に公開

CLIツールを作っていると、必ずと言っていいほど書くことになるバリデーションコード。TypeScriptの型推論を活用したら、このコードをほぼ不要にできたお話をします。

きっかけは、いろんなプロジェクトのコードを読んでいて気づいたことでした。どのCLIツールにも、似たようなバリデーションコードが含まれているんです。例えばこんな感じです:

if (!opts.server && opts.port) {
  throw new Error('--port requires --server flag');
}

if (opts.server && !opts.port) {
  opts.port = 3000; // デフォルトポート
}

// --portに値がなかったら?
// ポート番号が範囲外だったら?
// そもそも...

このコード自体は難しくありません。ただ、これがどこにでもあるというのが気になりました。すべてのプロジェクト、すべてのCLIツール。同じパターン、微妙に違う書き方。あるオプションが別のオプションに依存していたり、同時に使えないフラグがあったり、特定のモードでしか意味をなさない引数があったり。

そして思ったんです。この問題、他のデータ型ではもっとエレガントに解決しているのではないか、と。

バリデーションの何が問題か

Alexis Kingさんの「バリデーションせずパースせよ」(Parse, don't validate )という記事をご存知でしょうか。要点はシンプルです:データを緩い型にパースしてから有効性をチェックするのではなく、最初から有効な型にパースしなさい、という考え方です。

APIからJSONを受け取るとき、any型にパースしてからif文で検証するようなことはしませんよね。Zodのようなライブラリを使って、直接望む形にパースします。無効なデータはパーサーの段階で弾かれ、その後のコードは正しいデータだけを扱えばよくなります。

でもCLIでは、引数を適当なオブジェクトにパースして、その後100行かけてそのオブジェクトが妥当かチェックしているケースが多いです。もっといい方法があるはずです。

そこでOptiqueを作ってみました。大した話ではなく、単に同じバリデーションコードをあちこちで見る(そして書く)のが非効率だと感じたからです。

よく書いてしまう三つのパターン

依存関係のあるオプション

よくあるパターンです。あるオプションが、別のオプションが有効な時だけ意味を持つ。

従来の方法では、全部パースしてから後でチェックします:

const opts = parseArgs(process.argv);
if (!opts.server && opts.port) {
  throw new Error('--portには--serverが必要です');
}
if (opts.server && !opts.port) {
  opts.port = 3000;
}
// 他にもバリデーションが潜んでいるかも...

Optiqueなら、欲しい構造を記述するだけです:

const config = withDefault(
  object({
    server: flag("--server"),
    port: option("--port", integer()),
    workers: option("--workers", integer())
  }),
  { server: false }
);

TypeScriptが推論するconfigの型はこうなります:

type Config = 
  | { readonly server: false }
  | { readonly server: true; readonly port: number; readonly workers: number }

型システムが理解してくれるんです。serverfalseの時、portは文字通り存在しないundefinedでもnullでもなく、そもそもフィールドがありません。アクセスしようとすればTypeScriptがコンパイルエラーを出してくれます。ランタイムのバリデーションは不要になります。

排他的なオプション

これもよくある話です。出力形式を選ぶ:JSON、YAML、XML。でも同時に二つは選べません。

従来はこんなコードを書いていました:

if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
  throw new Error('出力形式は一つだけ選んでください');
}

(似たようなコード、みなさんも書いたことがあるのではないでしょうか)

Optiqueならこう書けます:

const format = or(
  map(option("--json"), () => "json" as const),
  map(option("--yaml"), () => "yaml" as const),
  map(option("--xml"), () => "xml" as const)
);

or()コンビネータは、ちょうど一つだけが成功することを保証します。結果は"json" | "yaml" | "xml"という単純な文字列。三つのブール値を管理する必要はありません。

環境別の必須オプション

本番環境には認証が必要。開発環境にはデバッグフラグ。Dockerにはまた違うオプション。よくある要件です。

バリデーションの迷路を作る代わりに、各環境の要件を記述するだけです:

const envConfig = or(
  object({
    env: constant("prod"),
    auth: option("--auth", string()),      // 本番では必須
    ssl: option("--ssl"),
    monitoring: option("--monitoring", url())
  }),
  object({
    env: constant("dev"),
    debug: optional(option("--debug")),    // 開発ではオプショナル
    verbose: option("--verbose")
  })
);

本番環境で認証なし?パーサーが即座に失敗します。開発モードで認証にアクセスしようとする?TypeScriptがコンパイルエラーを出します。その型にフィールドが存在しないからです。

「パーサーコンビネータ」という名前について

「パーサーコンビネータ」という名前、少し難しそうに聞こえるかもしれません。

実は私、CS専攻でもありませんし、大学で専門的に学んだわけでもありません。それでも何年かパーサーコンビネータを使ってきました。実際に使ってみると、名前から想像するほど複雑ではないんです。

設定ファイルやDSLのパースで使っていたのですが、CLIパースにも使えると気づいたのは、Haskellのoptparse-applicativeを見てからでした。「なるほど、この方法があったか」という発見でした。

基本的な考え方はシンプルです。パーサーは関数。コンビネータは、パーサーを受け取って新しいパーサーを返す関数。それだけです。

// これがパーサー
const port = option("--port", integer());

// これもパーサー(小さなパーサーから作られた)
const server = object({
  port: port,
  host: option("--host", string())
});

// まだパーサー(パーサーの入れ子)
const config = or(server, client);

モナドも圏論も必要ありません。ただの関数の組み合わせです。

TypeScriptの型推論が助けてくれる

興味深いことに、もうCLI設定の型を明示的に書く必要がありません。TypeScriptが推論してくれるからです。

const cli = or(
  command("deploy", object({
    action: constant("deploy"),
    environment: argument(string()),
    replicas: option("--replicas", integer())
  })),
  command("rollback", object({
    action: constant("rollback"),
    version: argument(string()),
    force: option("--force")
  }))
);

// TypeScriptが自動的に推論する型:
type CLI = 
  | { 
      readonly action: "deploy"
      readonly environment: string
      readonly replicas: number
    }
  | { 
      readonly action: "rollback"
      readonly version: string
      readonly force: boolean
    }

TypeScriptはaction"deploy"ならenvironmentは存在するけどversionは存在しないことを理解しています。replicasが数値であることも、forceがブール値であることも、すべて自動的に推論されます。

これは補完機能だけの話ではありません。(もちろん補完も便利ですが)バグを事前に防ぐという点が重要です。新しいオプションの処理をどこかで忘れた場合、コンパイルエラーになってすぐに気づけます。

実際に使ってみて

数ヶ月使ってみての感想を共有させてください。

コードが減りました。 リファクタリングではなく、純粋に削除です。CLIコードの30%近くを占めていたバリデーションロジックがなくなりました。

リファクタリングしやすくなりました。 CLIの引数の取り方を変更するのは、通常なら慎重な作業です。例えば--input file.txtから単にfile.txtという位置引数に変える場合、従来のアプローチだとあちこちのバリデーションロジックを確認する必要があります。Optiqueなら、パーサー定義を変更すると、TypeScriptが修正箇所をすべて教えてくれます。赤い波線を修正していくだけで完了です。

CLIの機能が充実しました。 複雑なオプション関係を追加しても、対応するバリデーションを書く必要がないので、自然と機能が増えていきました。排他的なオプショングループも、文脈依存のオプションも、パーサーが処理してくれます。

再利用性も高いです:

const networkOptions = object({
  host: option("--host", string()),
  port: option("--port", integer())
});

// どこでも再利用、違う組み合わせ
const devServer = merge(networkOptions, debugOptions);
const prodServer = merge(networkOptions, authOptions);
const testServer = merge(networkOptions, mockOptions);

そして何より、コンパイルが通れば動作する、という安心感があります。「たぶん動く」ではなく、確実に動くという確信があります。

どんな場合に向いているか

シンプルなスクリプトで引数が一つだけなら、ここまでする必要はないかもしれません。process.argv[2]で十分です。

ただ、こんな経験があるなら検討の価値があるかもしれません:

  • バリデーションロジックと実際のオプションの同期が取れなくなったことがある
  • 本番環境で特定のオプションの組み合わせが問題を起こしたことがある
  • なぜ特定のオプションの組み合わせで動作しないのか、デバッグに時間をかけたことがある
  • 同じような「オプションAにはオプションBが必要」というチェックを何度も書いている

Optiqueはまだ開発初期段階で、APIも今後変更される可能性があります。ただ、「バリデーションせずパースせよ」という基本的な考え方は変わりません。この数ヶ月、バリデーションコードを書く必要がなくなったのは確かです。

まだ慣れない部分もありますが、良い方向への変化だと感じています。

試してみませんか?

興味を持っていただけたら:

Optiqueは、すべてのCLI問題を解決する万能薬ではありません。ただ、同じバリデーションコードを繰り返し書くという非効率な作業を減らせるツールを作ってみた、ということです。

今書こうとしているバリデーションコード、本当に必要でしょうか?一度立ち止まって考えてみる価値はあるかもしれません。

Discussion