try-catch に疲れたので、TypeScript で別の書き方を考えた
JS/TSを長く触れていると、無意識に受け入れてしまっている不便さがあります。その一つが、TypeScriptにおける非同期処理のエラーハンドリングです。
async/await の登場で非同期処理は劇的に書きやすくなりましたが、その裏で私たちは try-catch ブロックのネストと、型安全性の喪失という新たな問題と戦い続けてきました。
今回は、そんなモヤモヤと向き合った結果、
最終的に try-ok という小さなライブラリを作った話です。
私たちが抱える「try-catch」のモヤモヤ
TypeScriptで非同期処理を書くとき、誰もが一度は以下のようなコードを書いたことがあるはずです。
async function getUser(id: string) {
try {
const user = await api.fetchUser(id);
return user;
} catch (error) {
// ここがつらい
// errorは 'unknown' (または 'any') なので、補完も効かない
console.error(error);
// 仕方なくキャストしたりするが、いずれ問題になる
return null;
}
}
このコードを書くたびに、私は3つの「嫌な匂い」を感じていました。
-
型の喪失:
catchブロックに入った瞬間、TypeScriptの恩恵が消えます。エラーがErrorオブジェクトなのか、ネットワークエラーなのか、ただの文字列なのか分からないため、安全なハンドリングが難しくなります。 - 制御フローの分断: 構造化されてはいるものの、制御フローが直線的でなくなります。コードベースが大きくなると、どこで例外が投げられ、どこでキャッチされるのかを追うのが認知負荷になります。
-
スコープのネスト: 変数スコープを維持するために
letを使ったり、インデントが深くなったりと、可読性が下がります。
目指す方向:GoやRustのように「値」として扱う
他の言語に目を向けると、Go言語やRustではエラーを例外(Exception)ではなく、値(Value)として扱っています。
// Goの例
user, err := api.FetchUser(id)
if err != nil {
return nil, err
}
// ここからはuserが安全に使える
「エラーもただの戻り値の一つである」
この考え方をTypeScriptに持ち込めば、制御フローは上から下へとスムーズに流れ、型安全性も維持できるはずだと考えました。
すでにECMAScriptでも議論されている (Safe Assignment Operator)
実は、この「try-catchがつらい」という問題意識は、私個人のものではありません。JavaScriptの標準仕様を策定するTC39でも、Safe Assignment Operator (?=)という提案が議論されています。
// 提案されている将来の構文(?=)
const [error, response] ?= await fetch("https://api.example.com")
この案以外にもtry-catchからの問題を解決しようとした様々な情報を見つけることができたので、色んな開発者が同じ課題を感じているんだなーと思いました。
なぜ、標準提案(タプル)ではなく自作(オブジェクト)なのか?
「じゃあ、その提案に合わせてタプル(配列)形式で作ればいいのでは?」と思うかもしれません。しかし、TypeScriptの型安全性を極限まで高めるには、タプルよりも Discriminated Union(オブジェクト) の方が適していると私は判断しました。
標準提案のようなタプル形式 [error, data] の場合、TypeScriptの厳格な設定下では、エラーチェック後も data が undefined である可能性が完全に排除しきれないケースがあります。
一方で、これから紹介する try-ok が採用した { isError: boolean } を持つオブジェクト形式なら、少なくとも strict 設定下では、意図しない undefined を許しません。
- 標準提案のアプローチ: 言語仕様として構文をシンプルにする(JS全体の改善)
- try-okのアプローチ: 現在のTypeScript環境で、最も安全でミスが起きない型定義を提供する(実用性重視)
つまり、try-ok は単なる車輪の再発明ではなく、「TypeScriptユーザーのための、より堅牢な選択肢」 として設計しました。
自作ライブラリ「try-ok」のアプローチ
そこで作成したのが try-ok です。
コンセプトはシンプルです。
「Discriminated Union(判別可能なユニオン型)を使って、成功と失敗を厳格に区別する」。
使い方
npm install try-ok
import { tryOk } from "try-ok";
const getData = async() => {
// 戻り値は Result<T, E> 型
const result = await tryOk(api.fetchUser("123"));
// 1. まずエラーかどうかをチェック (isError フラグ)
if (result.isError) {
// ここでは result.error (Error型) にのみアクセス可能
// result.data にはアクセス不可(コンパイルエラー)
console.error(result.error);
return;
}
// 2. ここまで来れば成功が保証される
// ここでは result.data (User型) にのみアクセス可能
// result.error にはアクセス不可
console.log(result.data.name);
}
const UserProfile = () => {
const [result, setResult] = useState();
useEffect(() => {
tryOk(getData()).then(setResult);
}, []);
if (!result) {
return <div>Loading...</div>;
}
if (result.isError) {
return <div>Oops!</div>;
}
return <div>I'm so happy {result.data}</div>;
};
なぜこれが良いのか?
try-ok は内部で以下のような型定義を持っています。
type Ok<T> = { isError: false; data: T };
type Err<E> = { isError: true; error: E };
type Result<T, E> = Ok<T> | Err<E>;
このように isError という共通のプロパティ(判別子)を持たせることで、TypeScriptのコンパイラは以下の挙動を強制します。
-
result.isErrorをチェックしないと、dataにもerrorにも安全にアクセスできない。 -
isErrorがtrueなら、それはErr型であり、dataは存在しない。 -
isErrorがfalseなら、それはOk型であり、dataは確実に存在する。
これにより、「エラーハンドリングを忘れてデータにアクセスしてしまい、実行時エラーになる」という事故を型レベルで防ぐことができます。また、コードを読む人に対しても「ここで分岐している」という意図が明確に伝わります。
React Serverコンポーネントでの活用例
RSCで非同期処理を行う際も、非常に見通しが良くなります。
const UserProfile = () => {
const result = await tryOk(getData());
if (result.isError) {
return <div>Oops!</div>;
}
return <div>I'm so happy {result.data}</div>;
}
結論として
try-ok は「例外をなくす」ためのライブラリではありません。
「例外を、型として正面から扱う」ための選択肢です。
-
try-catchのネストから解放されたい -
unknown型のエラーと戦うのをやめたい - チーム全体でエラーハンドリングの漏れを防ぎたい
もし同じような悩みを持っているのであれば、ぜひ一度試してみてください。
Github Repository: https://github.com/Sangun-Kang/try-ok
Discussion