deno用のちょっとしたargparseの代替
実装に関する事柄
とりあえず型パズルは置いておいてヘルプメッセージの作成とかをやっていくか
色々考えた結果 choicesもほしいかも?
とりあえずunknownを渡してあげれば知らないフラグがやってきたときにも対応できそう。
alias要らなくない?
aliasの対応要らなくない?
booleanとか配列だけを受け取るようにすれば良くない?
冷静に型の挙動を整理すると
‐booleansのundefinedは存在しない
‐stringsはdefault,requiredが設定されていたときにundefinedは存在しない
‐stringsはcollectが指定されたとき配列になる
‐aliasは対応したくない
そして
‐booleansのdefaultがなければ埋める(未設定を用意しなくて良いでしょ)
‐negatableのときdefaultをtrueにする
‐unknownも設定がなかったら埋める
ヘルプメッセージは上書きで良いんじゃない?
とりあえずは完成した。
とりあえずjsrに挙げて使えるようにしたい。
あと型チェックのテストどうしようかな?と思ったりもしてたのだけど、これは単にdeno checkの結果を吐き出させてゴールデンデータと確認するだけで良さそう
(エラーメッセージまで確認せずファイル名と行番号だけあれば十分そう)
型パズルの知識
不足している知識のメモ
ちょっとした成約はこれで色々練習した
interfaceとかtypeでgenericsを使うとそれをわざわざ渡す必要が出てきてしまう。
interface Opt<K extends string> {
boolean: K[]
negatable?: K[]
}
const opt: Opt<"X" | "Y"> = {
boolean: ["X"],
negatable: ["X", "Y"]
}
- Optにわざわざunion typesを渡さないとだめ
- booleanに含まれない値をnegatableに渡せてしまう
🤔たぶん、string[]
をunion typesに変換するような機能が必要そう
一応、as constでそれ限定のものにはなるらしい。これにtype[number]
とかを指定して上げれば取れるかもしれない。
// [deno-ts] Type '["x", "y"]' is not assignable to type 'never'.
const xs: never = (["x", "y"] as const)
こんなユーティリティ型を作る事はできる。
// Utility type to convert an array of strings to a union of string literals
type ArrayToUnion<T extends readonly string[]> = T[number];
こんな感じで使えるんだけれど、as constとかreadonlyが必須なんだろうか?
// Utility type to convert an array of strings to a union of string literals
type ArrayToUnion<T extends readonly string[]> = T[number];
// Example usage
const stringArray = ["a", "b", "c"] as const;
// Infer the type of the array elements as a union of string literals
type StringUnion = ArrayToUnion<typeof stringArray>;
// StringUnion is now "a" | "b" | "c"
const example: StringUnion = "a"; // This is valid
// const exampleInvalid: StringUnion = "d"; // This would cause a TypeScript error
今まではkeyofでunion typeに変換していたのだけれどこれを配列にする必要がある。こういう問題が書けると良い。
const options = {
boolean: ["x", "y"]
negatable: ["x"]
};
- booleanに渡された値を型の範囲にする
- negatableはbooleanに渡された値以外利用できない
問題 (prompt)
typescriptを書いている。以下のような型Optionsを定義したい
- boolean, negatableの2つのフィールドを持つ。これらは文字列の配列型のサブセット
- booleanに渡された値のみをnegatableに渡すことができる
{boolean: ["x", "y"], negatable: ["x"]} // ok
{boolean: ["x"], negatable: ["x","y"]} // fail
{boolean: ["x"], negatable: ["z"]} // fail
それができたら、このOptionsの型に渡す型パラメーターを値から導出する関数newを定義したい。
new({boolean: ["x", "y"], negatable: ["x]}) // OK
回答
こんな感じ?
type Options<B extends string[], N extends string[]> = {
boolean: B;
negatable: N extends B[number][] ? N : never; // NがBのサブセットでなければnever
};
function createOptions<B extends string[], N extends B[number][]>(
options: Options<B, N>
): Options<B, N> {
return options;
}
// OK: NはBのサブセット
// [deno-ts] Type 'Options<string[], "x"[]>' is not assignable to type 'never'.
const opt1: never = createOptions({ boolean: ["x", "y"], negatable: ["x"] });
// エラー: negatableに"y"が含まれているがbooleanには含まれていない
// [deno-ts] Type 'Options<string[], ("x" | "y")[]>' is not assignable to type 'never'.
const opt2: never = createOptions({ boolean: ["x"], negatable: ["x", "y"] });
// エラー: negatableに"z"が含まれているがbooleanには含まれていない
// [deno-ts] Type 'Options<string[], "z"[]>' is not assignable to type 'never'.
const opt3: never = createOptions({ boolean: ["x"], negatable: ["z"] });
↑で受け取った値をkeyに展開できないとだめなのか。
うーん、as constが必要になるのか。。
function F<K extends readonly string[]>(xs: K): { [P in K[number]]: boolean } {
const ob: Record<string, boolean> = {}
xs.forEach((x) => ob[x] = true)
return ob as { [P in K[number]]: boolean }
}
// [deno-ts] Type '{ x: boolean; y: boolean; z: boolean; }' is not assignable to type 'never'.
const ob: never = F(["x", "y", "z"] as const)
こういう感じにstringの配列ではなく直接絞られた型の配列を持つようにするとうまくいく
function F<T extends string>(xs: { boolean: T[] }): { [P in T]: boolean } {
const ob: Record<string, boolean> = {}
xs.boolean.forEach((x) => ob[x] = true)
return ob as { [P in T]: boolean }
}
// [deno-ts] Type '{ x: boolean; y: boolean; z: boolean; }' is not assignable to type 'never'.
const ob: never = F({ boolean: ["x", "y", "z"] })
最初の方にやれていたのは何がだめだったんだろ?
こんな感じで両方持てる?
function F<B extends string, S extends string>(ob: { boolean: B[], string: S[] }): { [P in B]: boolean } & { [P in S]: string } {
const r: Record<string, boolean | string> = {}
ob.boolean.forEach((x) => r[x] = true)
ob.string.forEach((x) => r[x] = "")
return r as { [P in B]: boolean } & { [P in S]: string }
}
// [deno-ts] Type '{ x: boolean; y: boolean; z: boolean; } & { i: string; j: string; k: string; }' is not assignable to type 'never'.
const ob: never = F({ boolean: ["x", "y", "z"], string: ["i", "j", "k"] })
collectの対応とかも欲しくなった。
function F<B extends string, S extends string, C extends S>(ob: { boolean: B[], negatable?: B[], string: S[], collect: C[] }): { [P in B]: boolean } & { [P in Exclude<S, C>]: string } & { [P in C]: string[] } {
const r: Record<string, boolean | string | string[]> = {};
ob.boolean.forEach((x) => r[x] = !(ob.negatable || []).includes(x));
ob.string.forEach((x) => {
if (ob.collect.includes(x as C)) {
r[x] = [];
} else {
r[x] = "";
}
});
return r as { [P in B]: boolean } & { [P in Exclude<S, C>]: string } & { [P in C]: string[] };
}
// [deno-ts] Type '{ x: boolean; y: boolean; z: boolean; } & { i: string; k: string; } & { j: string[]; }' is not assignable to type 'never'.
const ob: never = F({ boolean: ["x", "y", "z"], negatable: ["x"], string: ["i", "j", "k"], collect: ["j"] });
console.log(ob); // { x: true, y: true, z: true, i: "", j: [], k: "" }
これはstring周りが怪しい
type RequiredKeys<T extends string[], U extends string[]> = Exclude<T[number], U[number]>;
function F<B extends string, S extends string, C extends S, R extends (B | S)>(ob: { boolean: B[], negatable?: B[], string: S[], collect: C[], required: R[] }): { [P in Extract<B, R>]: boolean } & { [P in Extract<B, RequiredKeys<B[], R[]>>]?: boolean } & { [P in Extract<S, Exclude<S, C>>]: string } & { [P in Extract<S, Exclude<S, C>>]?: string } & { [P in Extract<C, R>]: string[] } & { [P in Extract<C, RequiredKeys<C[], R[]>>]?: string[] } {
const r: Record<string, boolean | string | string[] | undefined> = {};
ob.boolean.forEach((x) => r[x] = !(ob.negatable || []).includes(x));
ob.string.forEach((x) => {
if (ob.collect.includes(x as C)) {
r[x] = [];
} else {
r[x] = "";
}
});
return r as { [P in Extract<B, R>]: boolean } & { [P in Extract<B, RequiredKeys<B[], R[]>>]?: boolean } & { [P in Extract<S, Exclude<S, C>>]: string } & { [P in Extract<S, Exclude<S, C>>]?: string } & { [P in Extract<C, R>]: string[] } & { [P in Extract<C, RequiredKeys<C[], R[]>>]?: string[] };
}
// Example usage
// [deno-ts] Type '{ x: boolean; } & { y?: boolean | undefined; z?: boolean | undefined; } & { i: string; k: string; } & { i?: string | undefined; k?: string | undefined; } & { j: string[]; } & {}' is not assignable to type 'never'.
const ob: never = F({ boolean: ["x", "y", "z"], negatable: ["x"], string: ["i", "j", "k"], collect: ["j"], required: ["x", "i", "j"] });
console.log(ob); // { x: true, y: undefined, z: undefined, i: "", j: [], k: undefined }
続き
色々頑張った結果うまくいきそう
倒せない型パズル
オリジナルのparseArgs()
を呼ぶ部分は仕方がないとしてTDefaultsにbooleanを渡す部分の型エラーを倒せないのだった。
まだまだいろいろあった
- ok 型のテストを書く
- 型のおかしなところを直す
- ok collectのオプションが無いときになぜかstring[]になる
- ok なぜか"help"とかしか渡せなくなる
- ok なぜかdefaultsでエラーが出る -> booleansがundefinedだからか。
- ok stringがundefinedのときとstringが[]のときで、collectのあたいのチェックが異なる
- ok booleanがundefinedのときのチェックが働かない
- ok どうせならstring,booleanがundefinedだった場合にはneverが良い
基本的にundefinedのときにneverではなくstringにになったりするのが悪かった
最終的にdefaultを諦めてrequiredとcollectだけを見るようにした
jsrに
まだ挙げてない
motivationというかusecaseというか
ヘルプメッセージの生成付きのCLIパーサーが欲しくなるタイミングは、少数の作品を愛でるときではなくたくさんの作品未満を嫌々触るときに必要になる。
作りが{丁寧,雑}なコマンドを{使いたい,使いたくない}の四象限で考えたときにの雑なものを嫌々使うとき(一ヶ月前の自分は他人)
そうなると1文字フラグに否定的な気持ちになるのも説明がつく。
他のひとはどうしているか?
たとえば、fresh等を覗いてみる
直接ヘルプメッセージの文字列を記述している。
個人的な気質
💭自分はインデントとかスペースの調節が嫌いなのかも。フリーハンドが苦手。
あとはドキュメントを読まずにとりあえずコマンドを実行してみたい
コピペでコマンドを入力せずシェルから手打ちで入力したいとかの気質も関係してそう
ちなみに細々とした調整がしたくなったら直接文字列の方が良いし、変に利用方法を覚えるより楽。
ヘルプメッセージ生成の悩み
問題は実行してみないと組み立てられるヘルプメッセージが分からなくなっていくほど複雑になっていった場合にどこかに結果のログを貼りたくなってしまう所
まぁなのでこのパッケージで対応できるのはオプションと精々数個程度のものそう。
what is command line options parser?
なんとなくCLIパーサーで型の変換までを含めるとやりすぎだと思うのはいわゆるJSONを作るまでを責務にするのがちょうど良いと思うからなのかも。
そこからの変換器は得意なものが色々あるでしょみたいな感じ。
そうなるとnumberくらいは欲しいかも…?
ただ、それこそconfigファイルを触っているときにはほとんど文字列と真偽値程度なきもするし、意外と数値関係はコード側に定数として持つことが多い気がする(paginationのときのlimit,offsetみたいなものはnumberのほうが嬉しい。findのときのmmin,mtimeとかもそうか)。
ネストした構造が必要になるか?というとそうでもないような気がしていてそれこそeslintなどでもflat configの対応がどうこうとか言われていた。
commanderが強め?
環境変数の読み込み
環境変数が読み込めると嬉しい。
環境変数とフラグが衝突したときは環境変数が勝つということで良いか?たぶんそちらのほうが使いやすい。
リテラルタイプで取得できるのは引数として渡してるオブジェクトの中だけだから嬉しい筈。
envvarでmapped typeで良いんじゃ
例えばこんな感じで使う。
import { parseArgs } from "../src/parse-args.ts";
const args = parseArgs(Deno.args, {
string: ["name"],
required: ["name"],
envvar: {
name: "OVERRIDE_NAME",
},
} as const)
⚠️collect optionがついていても環境変数だと1つしか渡せない
あとbooleanのものはnegatableでも無視して環境変数で設定する