Optique: CLI 解析器組合器

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
最近我製作了一個稱為 Optique 的實驗性 CLI 解析器函式庫。截至撰寫本文的 2025 年 8 月 21 日,它尚未發布 0.1.0 版本,但我認為這是個有趣的構想,所以想透過這篇文章來介紹它。
Optique 主要受到兩個不同函式庫的影響。一個是 Haskell 的 optparse-applicative 函式庫,從中我學到的教訓是 CLI 解析器也可以成為解析器組合器,而且這樣設計時非常實用。另一個是 TypeScript 使用者已經熟悉的 Zod。雖然主要概念來自 optparse-applicative,但由於 Haskell 和 TypeScript 是截然不同的語言,API 的組織方式有很大差異。因此,在 API 設計方面,我參考了 Zod 和其他幾個驗證函式庫。
Optique 像樂高積木一樣組合多個小型解析器和解析器組合器,來表達 CLI 應該具有的形式。例如,最基本的元件之一是 option()
:
const parser = option("-a", "--allow", url());
要執行這個解析器,可以使用 run()
API:(注意:run()
函數會隱含地讀取 process.argv.slice(2)
)
const allow: URL = run(parser);
在上面的程式碼中,我特意指定了 URL
類型,但即使不這麼做,它也會自動推斷為 URL
類型。這個解析器只接受 -a
/--allow=URL
選項。如果提供其他選項或參數,會產生錯誤。如果沒有提供 -a
/--allow=URL
選項,也會產生錯誤。
如果想要將 -a
/--allow=URL
選項設為非必要的選擇性選項,該怎麼做呢?這時可以使用 optional()
組合器來包裝 option()
解析器:
const parser = optional(option("-a", "--allow", url()));
執行這個解析器時,會得到什麼類型的結果呢?
const allow: URL | undefined = run(parser);
是的,結果類型為 URL | undefined
。
或者,我們可以允許多次使用 -a
/--allow=URL
選項。像這樣:
prog -a https://example.com/ -a https://hackers.pub/
要允許多次使用選項,我們使用 multiple()
組合器代替 optional()
組合器:
const parser = multiple(option("-a", "--allow", url()));
現在您應該能猜到結果類型會是什麼了吧?
const allowList: readonly URL[] = run(parser);
沒錯,結果類型是 readonly URL[]
。
那麼,如果我們想要添加一個與 -a
/--allow=URL
選項互斥的 -d
/--disallow=URL
選項,該怎麼做呢?這兩個選項不能同時使用。這種情況下,我們可以使用 or()
組合器:
const parser = or(
multiple(option("-a", "--allow", url())),
multiple(option("-d", "--disallow", url())),
);
這個解析器可以正確接受以下命令:
prog -a https://example.com/ --allow https://hackers.pub/
prog -d https://example.com/ --disallow https://hackers.pub/
但是當 -a
/--allow=URL
選項和 -d
/--disallow=URL
選項混合使用時,會產生錯誤:
prog -a https://example.com/ --disallow https://hackers.pub/
那麼,這個解析器的結果會是什麼類型呢?
const result: readonly URL[] = run(parser);
糟糕,由於 or()
組合器包裝的兩個解析器都產生 readonly URL[]
類型的值,所以結果類型變成了 readonly URL[] | readonly URL[]
,最終簡化為 readonly URL[]
。我們希望能將其改為適當的可辨識聯合類型(discriminated union)。以下是我們想要的類型:
const Result =
| { mode: "allowList"; allowList: readonly URL[] }
| { mode: "blockList"; blockList: readonly URL[] };
當我們想要創建這種物件結構時,可以使用 object()
組合器:
const parser = or(
object({
mode: constant("allowList"),
allowList: multiple(option("-a", "--allow", url())),
}),
object({
mode: constant("blockList"),
blockList: multiple(option("-d", "--disallow", url())),
}),
);
為了添加辨識符(discriminator),我們還使用了 constant()
解析器。這是一個特殊的解析器,它不讀取任何內容,只產生給定的值。也就是說,它是一個永遠成功的解析器。它主要用於構建可辨識聯合類型,但也可以用於其他創意用途。
現在這個解析器會產生我們想要的類型結果:
const result:
| { readonly mode: "allowList"; readonly allowList: readonly URL[] }
| { readonly mode: "blockList"; readonly blockList: readonly URL[] }
= run(parser);
or()
組合器和 object()
組合器不僅可用於互斥選項,還可以用於實現子命令。讓我們介紹 command()
解析器(匹配一個命令)和 argument()
解析器(匹配位置參數):
const parser = command(
"download",
object({
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
)
上面的解析器會匹配以下命令:
prog download --target=out/ https://example.com/ https://example.net/
解析器的結果類型如下:
const result: {
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
} = run(parser);
如果要添加 upload
子命令,該怎麼做呢?沒錯,使用 or()
組合器:
const parser = or(
command(
"download",
object({
action: constant("download"),
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
),
command(
"upload",
object({
action: constant("upload"),
url: option("-d", "--dest", "--destination", url()),
files: multiple(
argument(file({ metavar: "FILE", type: "file" })),
{ min: 1 },
),
})
),
);
現在這個解析器可以接受以下命令:
prog upload ./a.txt ./b.txt -d https://example.com/
prog download -t ./out/ https://example.com/ https://hackers.pub/
這個解析器的結果類型如下:
const result:
| {
readonly action: "download";
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
}
| {
readonly action: "upload";
readonly url: URL;
readonly files: readonly string[];
}
= run(parser);
使用相同的方法,我們也可以實現嵌套子命令(nested subcommands),對吧?
以上就是 Optique 表達 CLI 的方式。您覺得如何?您是否認為 Optique 的方法適合表達複雜的 CLI?
當然,Optique 的方法也不是完美的。對於非常典型和簡單的 CLI 定義,它可能需要更多的代碼。此外,Optique 僅作為 CLI 解析器,不提供一般 CLI 應用框架所提供的各種功能。(不過我們計劃在未來為 Optique 添加更多功能...)