TypeScriptで型安全な同期・非同期モード切り替えを実装する
CLIパーサーライブラリOptiqueに、同期・非同期(sync/async)モードのサポートを追加しました。実際に着手してみると、型安全性を維持しながらこれを実現するのは予想以上に骨の折れる作業でした。特にobject()コンビネータでは、複数の子パーサーからモードを合成する必要があり、TypeScriptの型推論がエッジケースにぶつかることも多かったです。
Optiqueとは
Optiqueは、Haskellのoptparse-applicativeにインスパイアされた、TypeScript向けの型安全なコンビネータ型CLIパーサーです。デコレータやビルダーパターンではなく、小さなパーサーをコンビネータで組み合わせて大きなパーサーを作り、TypeScriptが結果の型を推論してくれます。
簡単な例を見てみましょう。
import { object } from "@optique/core/constructs";
import { argument, option } from "@optique/core/primitives";
import { string, integer } from "@optique/core/valueparser";
import { run } from "@optique/run";
const cli = object({
name: argument(string()),
count: option("-n", "--count", integer()),
});
// TypeScriptが推論: { name: string; count: number | undefined }
const result = run(cli); // デフォルトは同期
どれだけ深くネストしても型推論が効くので、ほとんどの場合、明示的な型注釈は不要です。
きっかけ
Lucas Garron氏(@lgarron)から、シェル補完で非同期処理をサポートしてほしいというイシューが上がりました。git for-each-refのようなシェルコマンドを実行して、ブランチやタグの一覧をタブ補完の候補として提供したいというユースケースです。
// Lucasの例: Gitのブランチとタグを並列で取得
const [branches, tags] = await Promise.all([
$`git for-each-ref --format='%(refname:short)' refs/heads/`.text(),
$`git for-each-ref --format='%(refname:short)' refs/tags/`.text(),
]);
正直なところ、最初は乗り気ではありませんでした。OptiqueのAPIは全て同期的で、それがシンプルさの源泉でした。一つの非同期関数が上流の全てを非同期に「感染」させてしまう問題も避けられていました。シェル補完は瞬時に返すべきで、非同期データが必要なら起動時にキャッシュすればいいと主張しました。
しかし、Lucas氏は反論しました。ファイルシステムも一種のデータベースだし、Gitのrefは常に変化する。大きなリポジトリで起動時に全てをキャッシュするのは現実的ではない、と。確かにその通りです。
解くべき問題
では、コンビネータ型のパーサーライブラリで、型安全性を保ちながら同期と非同期の両方をサポートするにはどうすればいいでしょうか。
要件を整理すると、こうなります。
-
parse()はTまたはPromise<T>を返す -
complete()はTまたはPromise<T>を返す -
suggest()はIterable<T>またはAsyncIterable<T>を返す - パーサーを組み合わせるとき、どれか一つでも非同期なら、結果も非同期になる
- 既存の同期コードはそのまま動く
4番目の要件が厄介です。こんな場合を考えてみてください。
const syncParser = flag("--verbose");
const asyncParser = option("--branch", asyncValueParser);
// この型は何になる?
const combined = object({ verbose: syncParser, branch: asyncParser });
フィールドの一つが非同期なので、合成されたパーサーも非同期になるべきです。つまり、型レベルでモードを計算するロジックが必要になります。
5つの設計案
いくつかのアプローチを検討しました。それぞれにトレードオフがあります。
案A:モードパラメータと条件型
Parserにモード型パラメータを追加し、条件型で切り替えます。
type Mode = "sync" | "async";
type ModeValue<M extends Mode, T> = M extends "async" ? Promise<T> : T;
interface Parser<M extends Mode, TValue, TState> {
parse(context: ParserContext<TState>): ModeValue<M, ParserResult<TState>>;
// ...
}
課題は、モードの合成です。
type CombineModes<T extends Record<string, Parser<any, any, any>>> =
T[keyof T] extends Parser<infer M, any, any>
? M extends "async" ? "async" : "sync"
: never;
案B:デフォルト値付きモードパラメータ
案Aの変形で、モードパラメータを最初に置き、デフォルトを"sync"にします。
interface Parser<M extends Mode = "sync", TValue, TState> {
readonly $mode: M;
// ...
}
デフォルト値があるので、既存のコードは変更なしで動きます。
案C:別々のインターフェース
ParserとAsyncParserを完全に分けて、明示的に変換します。
interface Parser<TValue, TState> { /* syncメソッド */ }
interface AsyncParser<TValue, TState> { /* asyncメソッド */ }
function toAsync<T, S>(parser: Parser<T, S>): AsyncParser<T, S>;
理解しやすいですが、コードの重複と明示的な変換が必要です。
案D:suggest()のみユニオン型(union type)
最小限のアプローチです。suggest()だけ非同期を許可します。
interface Parser<TValue, TState> {
parse(context: ParserContext<TState>): ParserResult<TState>; // 常に同期
suggest(context: ParserContext<TState>, prefix: string):
Iterable<Suggestion> | AsyncIterable<Suggestion>; // どちらでも可
}
元のユースケースには対応できますが、parse()を非同期にしたくなったときに困ります。
案E:fp-tsスタイルの高カインド型シミュレーション
fp-tsの手法を使って、高カインド型(Higher-Kinded Types)をシミュレートします。
interface URItoKind<A> {
Identity: A;
Promise: Promise<A>;
}
type Kind<F extends keyof URItoKind<any>, A> = URItoKind<A>[F];
interface Parser<F extends keyof URItoKind<any>, TValue, TState> {
parse(context: ParserContext<TState>): Kind<F, ParserResult<TState>>;
}
最も柔軟ですが、学習コストが高いです。
プロトタイプで検証
理論的な分析だけで決めるのではなく、TypeScriptの型推論が実際にどう振る舞うか、プロトタイプを作って検証しました。結果はイシューにまとめています。
両方のアプローチとも、型レベルで「どれか一つが非同期なら全体も非同期」というルールを正しく処理できます。(中略)
ModeValue<CombineParserModes<T>, ParserResult<TState>>のような複雑な条件型は、実装内部で明示的な型キャストが必要になることがあります。ただし、これはライブラリ内部の話であり、ユーザー向けAPIはクリーンなままです。
プロトタイプの結果、案B(デフォルト付きモードパラメータ)が有効だと確認できました。選んだ理由は以下の通りです。
-
後方互換性:デフォルトが
"sync"なので、既存コードがそのまま動く -
明示的:モードが型と実行時(
$modeプロパティ)の両方で見える - デバッグしやすい:実行時にモードを確認できる
- IDEサポートが良い:型情報が予測しやすい
CombineModesの仕組み
CombineModes型は、合成されたパーサーが同期か非同期かを計算します。
type CombineModes<T extends readonly Mode[]> = "async" extends T[number]
? "async"
: "sync";
モードのタプルのどこかに"async"があれば、結果は"async"。なければ"sync"です。
object()のようなコンビネータでは、パーサーオブジェクトからモードを抽出して合成する必要があります。
// 単一パーサーからモードを抽出
type ParserMode<T> = T extends Parser<infer M, unknown, unknown> ? M : never;
// レコード内の全パーサーからモードを合成
type CombineObjectModes<T extends Record<string, Parser<Mode, unknown, unknown>>> =
CombineModes<{ [K in keyof T]: ParserMode<T[K]> }[keyof T][]>;
実行時の実装
型システムはコンパイル時の安全性を担保しますが、実行時のロジックも必要です。各パーサーは$modeプロパティで実行モードを示します。
const syncParser = option("-n", "--name", string());
console.log(syncParser.$mode); // "sync"
const asyncParser = option("-b", "--branch", asyncValueParser);
console.log(asyncParser.$mode); // "async"
コンビネータは構築時にモードを計算します。
function object<T extends Record<string, Parser<Mode, unknown, unknown>>>(
parsers: T
): Parser<CombineObjectModes<T>, ObjectValue<T>, ObjectState<T>> {
const parserKeys = Reflect.ownKeys(parsers);
const combinedMode: Mode = parserKeys.some(
(k) => parsers[k as keyof T].$mode === "async"
) ? "async" : "sync";
// ... 実装
}
APIの洗練
議論の中で、Lucas氏から重要な提案がありました。run()がパーサーのモードに応じて自動的に同期・非同期を選ぶのではなく、別々の関数を用意すべきだという意見です。
run(…)は自動でいいとして、runSync(…)とrunAsync(…)で期待するモードをコンパイル時に強制できると良いのでは。
結果として、こうなりました。
-
run():パーサーのモードに応じて自動 -
runSync():コンパイル時に同期モードを強制 -
runAsync():コンパイル時に非同期モードを強制
// 自動: 同期パーサーならT、非同期パーサーならPromise<T>
const result1 = run(syncParser); // string
const result2 = run(asyncParser); // Promise<string>
// 明示的: コンパイル時に強制
const result3 = runSync(syncParser); // string
const result4 = runAsync(asyncParser); // Promise<string>
// コンパイルエラー: 非同期パーサーにrunSyncは使えない
const result5 = runSync(asyncParser); // 型エラー!
同じパターンをparse()/parseSync()/parseAsync()とsuggest()/suggestSync()/suggestAsync()にも適用しました。
非同期の値パーサーの作成
新しいAPIを使うと、Gitブランチ用の非同期の値パーサーはこう書けます。
import type { Suggestion } from "@optique/core/parser";
import type { ValueParser, ValueParserResult } from "@optique/core/valueparser";
function gitRef(): ValueParser<"async", string> {
return {
$mode: "async",
metavar: "REF",
parse(input: string): Promise<ValueParserResult<string>> {
return Promise.resolve({ success: true, value: input });
},
format(value: string): string {
return value;
},
async *suggest(prefix: string): AsyncIterable<Suggestion> {
const { $ } = await import("bun");
const [branches, tags] = await Promise.all([
$`git for-each-ref --format='%(refname:short)' refs/heads/`.text(),
$`git for-each-ref --format='%(refname:short)' refs/tags/`.text(),
]);
for (const ref of [...branches.split("\n"), ...tags.split("\n")]) {
const trimmed = ref.trim();
if (trimmed && trimmed.startsWith(prefix)) {
yield { kind: "literal", text: trimmed };
}
}
},
};
}
parse()が実際には同期なのにPromise.resolve()を返しているのに気づいたでしょうか。ValueParser<"async", T>型では、全てのメソッドが非同期シグネチャを使う必要があるためです。Lucas氏はこれをちょっとした煩わしさとして指摘しました。suggest()だけ非同期にしたいのに、parse()もPromiseでラップしなければならない、と。
メソッドごとにモードを分ける設計(例えばValueParser<ParseMode, SuggestMode, T>)も検討しましたが、実装の複雑さが大幅に増すため見送りました。今のところ、回避策はシンプルです。
// 方法1: Promise.resolve()を使う
parse(input) {
return Promise.resolve({ success: true, value: input });
}
// 方法2: 非同期にしてlinterを抑制
// biome-ignore lint/suspicious/useAwait: sync implementation in async ValueParser
async parse(input) {
return { success: true, value: input };
}
コスト
同期・非同期両対応は、Optiqueの内部に大きな複雑さをもたらしました。全てのコンビネータを更新する必要がありました。
- 型シグネチャがモードパラメータで複雑に
- 全コンビネータにモード伝播ロジックを追加
- 同期と非同期の両方のコードパスを実装
- TypeScriptを満足させるための型キャストが時々必要
例えば、object()コンビネータは約100行から約250行に増えました。内部実装は、合成されたモードに基づいて条件分岐します。
if (combinedMode === "async") {
return {
$mode: "async" as M,
// ... Promiseチェーンを使った非同期実装
async parse(context) {
// ... 各フィールドのparse結果をawait
},
};
} else {
return {
$mode: "sync" as M,
// ... 同期実装
parse(context) {
// ... 各フィールドのparseを直接呼び出し
},
};
}
この重複は、同期のみのユースケースでランタイムオーバーヘッドを発生させないためのコストです。
学んだこと
ユーザーの声を聞きつつ、プロトタイプで検証する
最初の直感は非同期サポートに抵抗がありました。Lucas氏の粘りと具体例に説得されましたが、コミットする前にプロトタイプで検証しました。プロトタイプによって、純粋な設計分析では見落としていた実践的な問題(TypeScriptの型推論の限界など)が明らかになりました。
後方互換性は、実装の複雑さを引き受けてでも守る価値がある
デフォルトを"sync"にしたことで、既存コードは変更なしで動きます。これは意図的な選択です。ライブラリ内部でどれほど複雑な処理が必要になっても、それをユーザーに押し付けないことが重要です。
統一モード vs メソッドごとの粒度
統一モード(全メソッドが同じ同期・非同期モードを共有)を選び、メソッドごとの粒度は採用しませんでした。そのため、実際には非同期が不要なメソッドでもPromise.resolve()を書くことがありますが、代替案は型システムの複雑さが掛け算で増えることでした。
オープンな設計プロセス
設計プロセス全体が公開のGitHubイシューで行われました。Lucas氏、Giuseppe氏、その他の方々のアイデアが最終的なAPIを形作りました。runSync()/runAsync()の区別は、Lucas氏のフィードバックから直接生まれたものです。
おわりに
今回の機能は、Optiqueで実装した中でも特に難しいものの一つでした。TypeScriptの型システムは「どれか一つが非同期なら全体も非同期」というルールをコンパイル時にエンコードできるほど強力ですが、そこにたどり着くには慎重な設計とプロトタイピングが必要でした。
うまくいった理由は、ModeValue<M, T>のような条件型が同期と非同期の世界を橋渡しできることです。実装の複雑さというコストは払いますが、ユーザー向けAPIはクリーンで型安全なままです。
非同期対応を含むOptique 0.9.0は現在プレリリーステスト中です。試してみたい方は、PR #70をご覧いただくか、プレリリース版をインストールしてください。
npm add @optique/core@0.9.0-dev.286 @optique/run@0.9.0-dev.286
deno add --jsr @optique/core@0.9.0-dev.286 @optique/run@0.9.0-dev.286
フィードバックをお待ちしています!
Discussion