自作 Result 型が『型厳密さ』を失う瞬間:Union 型の増意と最大 Payload への逃避
TypeScript でエラーハンドリングを型安全に行うため、Result<T, E> 型を自作するのは一般的な手法です。しかし、この道を突き詰めると、ある時「型の美しさ」と「型の厳密さ」が両立しにくい境界線にぶつかります。
1. 理想と現実:推論はラベルを維持しない
まずは、標準的な Result 型を定義してみます。
type Ok<T> = { isOk: true; data: T };
type Err<E> = { isOk: false; err: E };
type Result<T, E> = Ok<T> | Err<E>;
この定義には、TypeScript の性質に起因する挙動があります。**型推論が自動で「畳み込まれない」**という点です。
複数の Result を返す関数を合成したとき、開発者が期待するのは Result<T, E1 | E2> という綺麗な形です。しかし、エディタのツールチップに表示されるのは、中身が解体された構造の羅列になります。
// 期待: Result<User, "NOT_FOUND" | "UNAUTHORIZED">
// 現実(LSPの表示): Ok<User> | Err<"NOT_FOUND"> | Err<"UNAUTHORIZED">
TypeScript にとって型エイリアスはあくまで別名であり、計算の過程で構造(ユニオン型)へと展開されます。合成を繰り返すたびに、ドメインとしての「Result」という名前が消え、オブジェクトの集合として表示されるようになります。
2. 「型を明示したくない」という性質との相性
開発時には「戻り値をいちいち書かず、推論に任せたい」という場面が多くあります。しかし、自作 Result で推論に全振りすると、型の不透明さが増していきます。
// 型を書かずに推論に任せた関数
const doSomething = () => {
const res1 = step1(); // Result<string, 'NOT_FOUND'>
if (!res1.type) return res1;
const res2 = step2(res1.value); // Result<number, 'CONFLICT'>
if (!res2.type) return res2;
return { type: true, value: res2.value } as const;
};
この関数の戻り値は Ok<number> | Err<"NOT_FOUND"> | Err<"CONFLICT"> と推論されます。
利用側からは Result という抽象的な名前が見えず、内部の実装詳細である具体的なエラーコードがそのまま外部へ漏れ出します。これを Result 型として整えるには、手動での型指定や、型を再構築する複雑なユーティリティが必要になります。
3. 妥協の着地点:最大 ErrorPayload
バラバラに展開される推論結果を「きれい」に見せ、型注釈を効率化するために、多くの現場では「HTTP ステータスコード等の共通エラーを一つの Union 型にまとめる」という手法が取られます。
type ErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT';
type AppResult<T> = Result<T, ErrorCode>;
これにより、推論結果は常に AppResult<T> と表示されるようになります。しかし、ここで型としての解像度が一段階失われます。
4. 「起きないはずのエラー」が混入する歪み
共通の ErrorCode を使うと、その関数が「絶対に返さないはずのエラー」まで型に含まれてしまいます。
例えば、**「認証済みユーザーが自分のプロフィールを取得する」**関数を考えてみましょう。
const getMyProfile = (): AppResult<User> => { ... };
const res = getMyProfile();
if (res.type === 'err') {
switch (res.error) {
case 'NOT_FOUND':
return 'ユーザーなし';
case 'UNAUTHORIZED': // え、ログイン済みのはずでは?
case 'CONFLICT': // 取得するだけなのに重複エラー?
return '起こるはずがないが、型定義にあるから書かざるを得ない';
}
}
共通化した瞬間にすべての関数が「すべてのエラーを出す可能性」を持ってしまうため、実際には発生しないエラーコードを処理するためのダミーコードが必要になるなど、型によるエラーの絞り込みが機能しにくくなります。
5. まとめ
実際の開発現場では、共通のエラー型を使い回すことで、推論の見た目や実装スピードを優先するのは極めて現実的な選択です。
しかし、自作の Result 型で「型推論の利便性」と「型の厳密な絞り込み」を両立させようとすると、TypeScript の仕様上、どうしても複雑な型パズルが必要になる場面が出てきます。
もし、エラー型の合成や推論の挙動を自分で制御することに限界を感じたときは、neverthrow や Effect といったライブラリの導入を検討してみるのも一つの手です。
これらのライブラリは、自作では手間がかかる「型の正規化(畳み込み)」や「厳密なエラーの追跡」を、高度な型定義によってスマートに解決してくれます。自作の Result 型が抱える性質を正しく理解した上で、どこまでを自前で管理し、どこからをライブラリに任せるか。自分たちのプロジェクトに合ったバランスを検討してみるのが、良い着地点かもしれません。
Discussion