error-handling-idioms
リポジトリ: ryoppippi/ccusage 分析日: 2026-02-16
概要
ccusage は @praha/byethrow の Result 型を採用し、try-catch に代わる関数型エラーハンドリングをコードベース全体で実践している。JSONL パース・外部 API フェッチ・ファイル I/O・コスト計算といった失敗しうる操作を Result.try() でラップし、Result.pipe() による合成、Result.isFailure() による早期リターン、Result.unwrap() によるデフォルト値付き値取り出しの3つのイディオムを軸に統一されたエラーハンドリング戦略を構築している。モノレポ内の6つのアプリで同一パターンが反復適用されており、プラクティスの一貫性と再現性が高い。
背景にある原則
エラーを値として扱う: throw/catch の制御フローではなく、成功/失敗を型で表現することで、エラーの伝播経路がコード上で明示的になる。TypeScript の型システムがエラーハンドリングの漏れを静的に検出できる利点がある。
- 根拠:
packages/internal/src/pricing.ts:339-361でcalculateCostFromTokensがResult.ResultAsync<number, Error>を返し、呼び出し側がエラーを型として受け取る設計。
- 根拠:
失敗は早期にスキップし、正常パスを浅く保つ:
if (Result.isFailure(result)) continue;パターンにより、ループ内の不正データを即座にスキップし、正常処理のネストを最小化する。- 根拠:
apps/codex/src/data-loader.ts:209-216やapps/ccusage/src/debug.ts:122-124で、失敗行をスキップして次のイテレーションに進む統一パターンが使われている。
- 根拠:
パイプラインで変換とエラーを同時に合成する:
Result.pipe()により、値の変換 (Result.map)・エラーのログ (Result.inspectError)・デフォルト値の提供 (Result.unwrap) を一つのフローにまとめる。手続き的な if/else チェーンが不要になる。- 根拠:
apps/ccusage/src/commands/statusline.ts:289-301で、非同期データロード -> コスト抽出 -> エラーログ -> デフォルト値、の4段パイプラインが一つの式として記述されている。
- 根拠:
try-catch は境界に限定する: Result 型を採用しつつも、ファイル I/O のストリーム処理やプロセス間通信など、Result に変換するコストが高い箇所では try-catch を許容する。全てを Result にするのではなく、境界を明確にする。
- 根拠: CLAUDE.md に "Keep traditional try-catch only for: file I/O with complex error handling, legacy code" と明記。
apps/mcp/src/cli-utils.ts:79-101で SubprocessError のハンドリングに try-catch を使用。
- 根拠: CLAUDE.md に "Keep traditional try-catch only for: file I/O with complex error handling, legacy code" と明記。
実例と分析
Result.try() による throw 可能な操作のラップ
コードベース全体で JSON パース、ファイル読み込み、外部 API 呼び出しなど throw する可能性のある操作を Result.try() でラップしている。同期操作と非同期操作で使い分けがある。
同期操作 -- try に関数を渡す:
// apps/ccusage/src/debug.ts:116-119
const parseParser = Result.try({
try: () => JSON.parse(line) as unknown,
catch: () => new Error("Invalid JSON"),
});
const parseResult = parseParser();非同期操作 -- try に Promise を渡す:
// apps/codex/src/data-loader.ts:204-207
const statResult = await Result.try({
try: stat(directoryPath),
catch: (error) => error,
});重要な違いとして、同期の Result.try() はファクトリ関数を返す(parseParser() で呼び出しが必要)のに対し、非同期では直接 await する、あるいは () で即時呼び出しするパターンが混在する。
Result.isFailure() + continue による早期スキップ
ループ内で不正なデータ行をスキップする統一パターン。JSONL パース失敗・スキーマバリデーション失敗のいずれでも同一のイディオムを使う:
// apps/codex/src/data-loader.ts:249-257
const parseLine = Result.try({
try: () => JSON.parse(trimmed) as unknown,
catch: (error) => error,
});
const parsedResult = parseLine();
if (Result.isFailure(parsedResult)) {
continue;
}このパターンは apps/ccusage/src/debug.ts:116-124、apps/amp/src/data-loader.ts:178-184 でも同一構造で使われており、モノレポ全体で統一されている。
Result.pipe() による関数合成パイプライン
最も高度な活用は Result.pipe() による複数操作の合成。エラーハンドリング・ログ・デフォルト値設定を一つの宣言的な式で表現する:
// apps/ccusage/src/_utils.ts:15-23
export async function getFileModifiedTime(filePath: string): Promise<number> {
return Result.pipe(
Result.try({
try: stat(filePath),
catch: (error) => error,
}),
Result.map((stats) => stats.mtime.getTime()),
Result.unwrap(0),
);
}より複雑な例として、statusline コマンドでは4段階のパイプラインが使われている:
// apps/ccusage/src/commands/statusline.ts:289-301
const getCcusageCost = async (): Promise<number | undefined> => {
return Result.pipe(
Result.try({
try: async () =>
loadSessionUsageById(sessionId, {
mode: "auto",
offline: mergedOptions.offline,
}),
catch: (error) => error,
})(),
Result.map((sessionCost) => sessionCost?.totalCost),
Result.inspectError((error) => logger.error("Failed to load session data:", error)),
Result.unwrap(undefined),
);
};Result.unwrap() によるデフォルト値付き値取り出し
Result から値を取り出す際に、失敗時のデフォルト値を指定するパターンが頻出する:
// apps/ccusage/src/data-loader.ts:642-645
return Result.unwrap(
fetcher.calculateCostFromTokens(data.message.usage, data.message.model),
0,
);// apps/opencode/src/cost-utils.ts:41
return Result.unwrap(result, 0);コスト計算ではデフォルト値 0 が統一的に使われ、「価格情報が取得できない場合は 0 として扱う」というビジネスルールを型安全に表現している。
Result.succeed() / Result.fail() による明示的な値の生成
条件分岐で成功/失敗を明示的に生成し、パイプラインに合流させる:
// packages/internal/src/pricing.ts:146-149
this.cachedPricing != null
? Result.succeed(this.cachedPricing)
: Result.fail(new Error('Cached pricing not available')),// packages/internal/src/pricing.ts:354-358
Result.andThen((pricing) => {
if (pricing == null) {
return Result.fail(new Error(`Model pricing not found for ${modelName}`));
}
return Result.succeed(this.calculateCostFromPricing(tokens, pricing));
}),LiteLLMPricingFetcher に見る高度なパイプライン合成
packages/internal/src/pricing.ts:145-197 の ensurePricingLoaded メソッドは、このコードベースで最も複雑な Result パイプラインの例である。キャッシュ確認 -> オフライン/オンライン分岐 -> フェッチ -> レスポンスバリデーション -> パース -> キャッシュ保存 -> フォールバック、という7段階の処理を一つの式で表現している:
// packages/internal/src/pricing.ts:145-197 (構造を簡略化)
private async ensurePricingLoaded(): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {
return Result.pipe(
this.cachedPricing != null
? Result.succeed(this.cachedPricing)
: Result.fail(new Error('Cached pricing not available')),
Result.orElse(async () => {
if (this.offline) {
return this.loadOfflinePricing();
}
return Result.pipe(
Result.try({ try: fetch(this.url), catch: (error) => new Error(...) }),
Result.andThrough((response) => { /* validate status */ }),
Result.andThen(async (response) => Result.try({ try: response.json(), ... })),
Result.map((data) => { /* parse and build Map */ }),
Result.inspect((pricing) => { this.cachedPricing = pricing; }),
Result.orElse(async (error) => this.handleFallbackToCachedPricing(error)),
);
}),
);
}try-catch が残されている箇所
CLAUDE.md のガイドラインに従い、Result に変換するコストが高い箇所では try-catch が残されている:
// apps/mcp/src/cli-utils.ts:79-101
export async function executeCliCommand(...): Promise<string> {
try {
const result = await spawn(executable, args, { ... });
// ...
} catch (error: unknown) {
if (error instanceof SubprocessError) {
const message = (error.stderr ?? error.stdout ?? ...).trim();
throw new Error(message);
}
throw error;
}
}// apps/ccusage/src/data-loader.ts:563-590 (getEarliestTimestamp)
export async function getEarliestTimestamp(filePath: string): Promise<Date | null> {
try {
// ストリーム処理 + 内部の JSON パースも try-catch
await processJSONLFileByLine(filePath, (line) => {
try {
// ...
} catch {
// Skip invalid JSON lines
}
});
return earliestDate;
} catch (error) {
logger.debug(`Failed to get earliest timestamp for ${filePath}:`, error);
return null;
}
}注目すべきは、MCP サーバー (apps/mcp/src/ccusage.ts) では CLI を子プロセスとして呼び出すため、Result 型ではなく try-catch + 空オブジェクト返却のパターンを使っている点。プロセス境界では Result 型の恩恵が薄れるため、pragmatic な判断といえる。
パターンカタログ
Railway Oriented Programming (分類: 振る舞い / 関数型パターン)
- 解決する問題: 連鎖する操作のエラーハンドリングにおける分岐の爆発
- 適用条件: 複数の失敗しうる操作を順次合成する必要がある場面
- コード例:
packages/internal/src/pricing.ts:145-197--Result.pipe+Result.orElse+Result.andThenによる「成功レール / 失敗レール」の構築 - 注意点: パイプラインが深くなるとデバッグ時にスタックトレースが追いにくくなる。
Result.inspectErrorを要所に挟むことで可観測性を担保している
Null Object Pattern の変形 (分類: 振る舞い)
- 解決する問題: 失敗時に null/undefined を返すと呼び出し側で null チェックが必要になる
- 適用条件: 失敗時にデフォルト値で処理を続行できる場面
- コード例:
apps/ccusage/src/data-loader.ts:642-645--Result.unwrap(..., 0)でコスト 0 をデフォルト値にする - 注意点: デフォルト値が業務的に妥当か慎重に検討が必要(コスト 0 はサイレント失敗を意味する)
Good Patterns
- Result.pipe() + Result.inspectError() + Result.unwrap() の三点セット: 非同期操作のエラーを値に変換し、ログを残しつつデフォルト値で処理を続行する。statusline コマンド (
apps/ccusage/src/commands/statusline.ts:339-358) で頻出するこのパターンは、UI 層がエラーで止まらないことを保証しながら、デバッグ情報を失わない。
// apps/ccusage/src/commands/statusline.ts:339-358
const todayCost = await Result.pipe(
Result.try({
try: async () => loadDailyUsageData({ ... }),
catch: (error) => error,
})(),
Result.map((dailyData) => { /* 集計 */ }),
Result.inspectError((error) => logger.error('Failed to load daily data:', error)),
Result.unwrap(0),
);- Result.try() + isFailure() + continue のループスキップイディオム: JSONL の各行パースでは、不正行をサイレントにスキップし、有効なデータのみを処理する。ネストを最小化し、正常パスの可読性を保つ。
// apps/codex/src/data-loader.ts:249-257
const parseLine = Result.try({
try: () => JSON.parse(trimmed) as unknown,
catch: (error) => error,
});
const parsedResult = parseLine();
if (Result.isFailure(parsedResult)) {
continue;
}- Result.andThrough() によるバリデーション挿入: パイプラインの途中にバリデーションを挟み、条件を満たさなければ失敗レールに切り替える。値を消費せず通過させる点が
andThenとの違い。
// packages/internal/src/pricing.ts:162-167
Result.andThrough((response) => {
if (!response.ok) {
return Result.fail(new Error(`Failed to fetch pricing data: ${response.statusText}`));
}
return Result.succeed();
}),Anti-Patterns / 注意点
- Result.unwrap() でのサイレント失敗:
Result.unwrap(0)は便利だが、エラーが発生してもデフォルト値0で処理が続行されるため、問題に気づきにくい。コスト計算で価格情報が取得できない場合、0 ドルと報告されるリスクがある。
// Bad: エラーを無視してデフォルト値で続行
return Result.unwrap(
fetcher.calculateCostFromTokens(data.message.usage, data.message.model),
0,
);
// Better: inspectError でログを残してからデフォルト値を使う
return Result.pipe(
fetcher.calculateCostFromTokens(data.message.usage, data.message.model),
Result.inspectError((error) => logger.warn("Cost calculation failed:", error)),
Result.unwrap(0),
);- 同期/非同期の Result.try() 呼び出し規約の混在: 同期版は
Result.try({...})()と即時呼び出しが必要だが、非同期版はawait Result.try({...})と書ける。この不統一がコードの認知負荷を高める。
// 同期: ファクトリ関数を即時呼び出し
const result = Result.try({ try: () => JSON.parse(line), catch: ... })();
// 非同期: Promise を直接渡して await
const result = await Result.try({ try: stat(path), catch: ... });
// 非同期でもファクトリ呼び出しが必要なケース (async 関数)
const result = Result.try({ try: async () => { ... }, catch: ... })();導出ルール
[MUST]throw する可能性のある操作は Result.try() でラップし、戻り値を Result 型にする。ただしプロセス境界(子プロセス呼び出し、ストリーム I/O)は try-catch を許容する- 根拠: ccusage ではJSON パース・ファイル読み込み・API フェッチを一律 Result.try() でラップし、MCP サーバーのプロセス間通信のみ try-catch を使うという境界を明確にしている(
apps/mcp/src/cli-utils.ts:79-101vsapps/codex/src/data-loader.ts:204-207)
- 根拠: ccusage ではJSON パース・ファイル読み込み・API フェッチを一律 Result.try() でラップし、MCP サーバーのプロセス間通信のみ try-catch を使うという境界を明確にしている(
[MUST]Result.unwrap() でデフォルト値を使う場合は Result.inspectError() でエラーログを残し、サイレント失敗を防止する- 根拠: statusline コマンドでは全ての unwrap の前に inspectError を配置しているが(
statusline.ts:299,357,455)、data-loader.ts の calculateCostForEntry ではログなしで unwrap(0) している(data-loader.ts:642-645)。後者はコストが 0 になる原因の追跡が困難になる
- 根拠: statusline コマンドでは全ての unwrap の前に inspectError を配置しているが(
[SHOULD]ループ内で失敗しうるパース処理はif (Result.isFailure(result)) continue;の早期スキップパターンを使い、正常パスのネストを浅く保つ- 根拠: JSONL パースのループ処理が codex/ccusage/amp の3つの data-loader で同一パターンで実装されており(
apps/codex/src/data-loader.ts:249-257)、正常パスのインデントが1段浅くなっている
- 根拠: JSONL パースのループ処理が codex/ccusage/amp の3つの data-loader で同一パターンで実装されており(
[SHOULD]複数の失敗しうる操作を連鎖させる場合は Result.pipe() で合成し、エラーの伝播と変換を宣言的に記述する- 根拠:
packages/internal/src/pricing.ts:145-197で7段階の操作チェーンを一つの式として記述し、各段階のエラーハンドリングが明示的になっている
- 根拠:
[SHOULD]Result 型を返すメソッドの戻り値型はResult.ResultAsync<T, Error>を明示し、呼び出し側がエラー型を認識できるようにする- 根拠:
LiteLLMPricingFetcherの全メソッドがResult.ResultAsync<T, Error>を戻り値型に明示しており(packages/internal/src/pricing.ts:200,215,239,347)、呼び出し側の型推論を助けている
- 根拠:
[AVOID]Result.unwrap() をログなしで使いデフォルト値でエラーを握りつぶすこと。デフォルト値が業務的に妥当かどうかの判断なしにunwrap(0)を使うと、計算結果の信頼性が損なわれる- 根拠:
apps/ccusage/src/data-loader.ts:642-645で inspectError なしのResult.unwrap(..., 0)が使われており、コスト計算のエラーが検知不可能になっている
- 根拠:
適用チェックリスト
- [ ] プロジェクトに Result 型ライブラリ(byethrow, neverthrow, oxide.ts 等)を導入し、throw する操作のラップ方針を CLAUDE.md / CONTRIBUTING.md に明記する
- [ ] JSON パース・外部 API 呼び出し・ファイル読み込みなど throw する操作を洗い出し、Result.try() 相当でラップする
- [ ] ループ内の失敗しうる操作に「isFailure -> continue」の早期スキップパターンを適用し、正常パスのネストを1段以下に保つ
- [ ] Result.unwrap() でデフォルト値を使う全箇所に inspectError / ログ出力を追加し、サイレント失敗を防止する
- [ ] Result 型を使わない境界(プロセス間通信、ストリーム処理等)を明確に定義し、チームで合意する
- [ ] Result.pipe() によるパイプライン合成を非同期エラーチェーンに導入し、手続き的な if/else チェーンを置き換える