TS 分析字串實現 Commander.js 自動強型別

華猾稽發表於2022-11-24

數字誤認作字元,字串誤認作陣列,Promise 沒有 await 就取值,這些問題在 TypeScript 裡把每個型別都定義對了就不會出現,還會有很好的編輯提示。

但寫命令列工具,定義一個某型別的選項時,一邊要傳參如 .option("-d, --dev"),一邊要標註型別如 { dev: boolean },兩個地方需要手動同步。繁瑣易錯,怎麼辦?TypeScript 早在 4.1 就可以設計分析字串生成型別了。

現在,透過 @commander-js/extra-typings 就可以自動得到字串中設計的命令結構。

import { program } from '@commander-js/extra-typings';

program
  .argument("<input>")
  .argument("[outdir]")
  .option("-c, --camel-case")
  .action((input, outputDir, options) => {
    // input 是 string
    // outputDir 是 string | undefined
    // options 是 { camelCase?: true | undefined }
  });

本文介紹 @commander-js/extra-typings 用到的關鍵技術。

必需 / 可選,單個 / 數個

必須 / 可選引數 往往形如 <xxx> / [xxx],其中 xxx 為引數名。
引數名以 ... 結尾時,表示該引數可以包含多個取值。

對於這樣的字串,使用 extends 關鍵字即可設計條件對應型別

// S 取 "<arg>" 得 true
// S 取 "[arg]" 得 false
type IsRequired<S extends string> =
  S extends `<${string}>` ? true : false;

// S 取 "<arg...>" 得 true
// S 取 "<arg>" 得 false
type IsVariadic<S extends string> =
  S extends `${string}...${string}` ? true : false;

選項名

選項名時常有精簡寫法,如 -r 可能表示 --recursive。作為命令列選項時通常使用 - 配合小寫字母的命名方式,在程式碼中則常用駝峰命名法。

對於使用 逗號+空格 來提前放置精簡寫法的選項,可以使用 infer 關鍵字推導模板文字遞迴化簡。

// S 取 "-o, --option-name" 得 "option-name"
type OptionName<S extends string> =
  S extends `${string}, ${infer R}`
    ? OptionName<R> // 去除逗號,空格,及之前的內容
    : S extends `-${infer R}`
      ? OptionName<R> // 去除開頭的 "-"
      : S;

將短線 - 轉換為駝峰命名,可以結合 Capitalize

// S 取 "option-name" 得 "optionName"
type CamelCase<S extends string> =
  S extends `${infer W}-${infer R}`
    ? CamelCase<`${W}${Capitalize<R>}`>
    : S;

變長引數

引數長度不定的函式,引數可以透過展開型別元組來定義型別

type Args = [boolean, string, number];

type VarArgFunc = (...args: Args) => void;

const func: VarArgFunc = (arg1, arg2, arg3) => {
  // arg1 為 boolean
  // arg2 為 string
  // arg3 為 number
};

型別元組可以儲存在類引數中,並同樣透過展開運算子 ... 來結合新元素。

declare class Foo<Args extends unknown[] = []> {
  concat<T>(arg: T): Foo<[...Args, T]>;
  run(fn: (...args: Args) => void): void;
}

const foo = new Foo()
  .concat(1)
  .concat("str")
  .concat(true);

foo.run((arg1, arg2, arg3) => {
  // arg1 為 number
  // arg2 為 string
  // arg3 為 boolean
});

限制

實現 @commander-js/extra-typings 遇到的最大障礙,在於對 this 資訊的保留。在變長引數一節,每次 concat 新增資訊都需要返回一個新例項,能不能使用 &mixin 等其他技術結合 this 呢?目前實測結果是 不能,TS 在這類實測中,非常容易報錯或卡死,不卡死時在某些地方會提示 TS 檢查陷入死迴圈,不卡死不報錯時往往是陷入了無響應的狀態。

相關記錄可以在原實現 PR #1758 · tj/commander.js 中找到。

這樣的限制也在 @commander-js/extra-typings 的介紹中有所體現,由於型別定義中每次都是返回一個新例項,

  • CommandOptionArgument 為基擴充子類時可能很難得到很好的型別支援;
  • 每步操作需要在上步操作的返回值上執行,以使用正確完整的型別資訊。

相關文章