🗣️

【TypeScript】引数を型付きで取得できるコマンドパーサ

5 min read

何が欲しかったの?

以下のようなコマンドがあるとします。

コマンドの構造
コマンドの構造

コマンドは「コマンド名」「必須の引数」「任意のオプション」で構成されます。

そこで、
引数やオプションの値に制約を設けて、パースした後は型付きで取得できるようにしたい!
と思い、作成しました。

制約

以下の条件で作っていきます。

  • コマンドは複数の「要素」からなり、それぞれの「要素」の間はスペースを1個開ける。
  • 最初の「要素」は「コマンド名」とする。
  • 「コマンド名」を除いた「要素」のうち、--[任意の文字列]の形式に当てはまるものを「オプション名」とし、その次の「要素」を「オプションの値」とする。
  • 「コマンド名」「オプション名」「オプションの値」のどれでもない要素を「引数」とする。

リンク

ソースコードはここです。

https://github.com/watano1168/typed-command-parser

具体的な使用例

1. 引数型を定義する。

定められた形式に従って「コマンドの引数値に指定する型」を作成します。

const types = {
  integer: {  // 識別子。コマンドの引数を指定するときに、キーとして使用する。
    name: "整数",  // 型名。エラー文に使用される。
    converter: (value: string) => {
      // 文字列を受け取り、型変換とバリデーションを行うメソッド。
      const num = parseInt(value);
      if (isNan(num)) return;  // バリデーションに通らなかった場合は、undefinedを返す。
      return num;
    }
  },
  float: { /* something */ },
  string: { /* something */ },
  userId: { /* something */ }
} as const;

2. コマンド形式を定義する。

定められた形式に従って「コマンド名」「引数」「オプション」の形式を指定します。

const format = {
  prefixes: ["/timeout", "!timeout"],  // コマンド名。複数指定できる。
  arguments: [  // 必須となる引数。複数指定できる。
    {
      name: "対象のユーザー",  // 引数名。エラー文で使用される。
      type: "userId"  // 型識別子。
    },
    {
      name: "タイムアウト時間(分)",
      type: "float"
    }
  ],
  options: {  // 任意のオプション。複数指定できる。
    message: {
      name: "送信する警告文",
      type: "string"
    }
  }
} as const;

3. 文字列をパースする。

parseCommand 関数を呼び出します。

const command = "/timeout 1234567890 0.5 --message \"You are timed out.\"";
const result = parseCommand(command, format, types);

4. 結果が返ってくる。

それぞれの引数・オプションには、型が付いて返ってきます。

// コマンド名が違う時は、undefinedが返ってくる。
if (!result) return;

// それぞれの引数値には、型が付いて返ってくる。
const prefix: string = result.prefix;
const argument1: string = result.arguments[0];
const argument2: number = result.arguments[1];
const messageOption: string | undefined = result.options.message;

5. エラーが発生した場合、エラー箇所を示してくれる。

エラーが発生した場合には、その原因を独自エラー型で示してくれます。

  • 渡された引数の個数が、コマンドの形式と異なる場合
    例)3個の引数を求めているのに、2個しか引数が渡されていない場合
  • 渡されたオプションの中に、コマンドの形式に存在しないものがある場合
    例)messageオプションのみを受け付けているのに、pageオプションが渡された場合
  • 渡された引数やオプションの形式が、制約を守っていない場合
    例)整数を渡すべきところに、abcが渡された場合

以下の出力は、正常終了した場合は「パースの結果」を、エラーが発生した場合には「エラー文」を表示しています。

/timeout 1234567890 0.5 --message "You are timed out."
{
  prefix: '/timeout',
  arguments: [ '1234567890', 0.5 ],
  options: { message: 'You are timed out.' }
}

/timeout 9876543210 1.0
{ prefix: '/timeout', arguments: [ '9876543210', 1 ], options: {} }

/timeout 1234567890 hoge
引数の形式が違います。
2 番目の引数 には 実数 を入力してください。

/timeout 1234567890 --message "You are timed out."
引数の数が違います。2 個の引数が必要ですが、1 個入力されています。

/timeout 1234567890 0.5 --message "You are timed out." --page 2
オプション "page" は要求されていません。

!timeout 1234567890 0.5 --message "You are timed out."
{
  prefix: '!timeout',
  arguments: [ '1234567890', 0.5 ],
  options: { message: 'You are timed out.' }
}

実装方法の概要

コードを見ると分かりますが、かなりの型芸です。
さらに言うと、一部やむを得ず as による強制型変換を使用しています。ごめんなさい。

1. 入力文字列を分割する

https://github.com/watano1168/typed-command-parser/blob/main/src/command-parser/fragment.ts

入力された文字列を、「コマンド名」「引数」「オプション」部分に分割します。
ここではまだ型変換を行いません。

具体的には、string型を引数として受け取り、以下のような型の値を返します。

type CommandFragments = {
  readonly prefix: string;  // コマンド名
  readonly arguments: readonly string[];  // 引数値の配列
  readonly options: { [name: string]: string };  // オプション名とその値
};

実装の中身では、正規表現によるマッチングを繰り返しています。
ここで「入力文字列の最初の部分」と「指定されたコマンド名」が一致しない場合、その時点でundefinedを返します。

2. コマンドの要素をチェックする

https://github.com/watano1168/typed-command-parser/blob/main/src/command-parser/interpret/label.ts

以下の内容をチェックし、コマンドの形式に合わない場合はエラーを発生させます。

  • 引数の個数が、コマンドの形式と異なる場合
  • オプションの中に、コマンドの形式に存在しないものがある場合

また、ここで入力される引数は「リスト」ですが、引数の個数を型情報に含めるために「タプル」に変換します。

リストをタプルに変換するために、asの強制型変換を使用しています。1敗。

3. 型変換・バリデーションを行う

https://github.com/watano1168/typed-command-parser/blob/main/src/command-parser/interpret/converter.ts

入力された各引数・オプションに対して、ここで指定したconverter関数を適用します。
バリデーションで入力値が制約に適合しなかった場合は、エラーを発生させます。

タプルに対してmapを実行した際に、それぞれの値の型が保持されないため、asの強制型変換を使用しています。2敗。

オブジェクトのkeyに対してmapforを実行した際に、それぞれのkeyのリテラル型が保持されないため、asの強制型変換を使用しています。3敗。

感想

個人プロジェクトで使っているのですが、結構便利です。それにロマンがあります
しかし、メンテナンス性が皆無です。
普段は引数を1個ずつ地道に、型変換・バリデーションしましょう。
ただし、入力文字列を分割する部分は正規表現でマッチしているだけなので、使い回せると思います。

また、作成の段階で試行錯誤をしたこともあり、TypeScriptの型システムや機能を知ることができました。型が循環参照を起こしたりして結構大変でした...

Discussion

ログインするとコメントできます