TypeScriptを長年書いてきた誰もが経験する問題
こんなコードを書いたことはないだろうか?ネストされたtry/catchの連鎖、tryの中のPromise.all、そして外側のcatch——チームの誰もがどのステップでどんなエラーが起きるか確信が持てない状態だ。
async function processOrder(orderId: string) {
try {
const order = await fetchOrder(orderId); // ネットワークエラー?
try {
const payment = await chargeCard(order); // 支払いエラー?
await sendEmail(order, payment); // メールエラー?
} catch (paymentErr) {
// 個別処理...でもメールエラーもここに入る?
}
} catch (err) {
// errは'unknown'型 — どんな型か不明
console.error(err);
}
}
devでは問題なく動くが、productionは違う。errの型はunknown——NetworkErrorなのか、PaymentDeclinedErrorなのか、それとも普通の例外なのか判断できない。TypeScriptコンパイラは完全に沈黙している。エラーが表面化するのは、顧客が問題に直面したときだけだ。
私はEffect TSに移行する前の6ヶ月間、そのようなコードベースを保守していた——その違いはチームが戻りたがらないほど明確だった。
TypeScriptのエラー処理アプローチ比較
振り返ってみると、主なアプローチは4つある——それぞれに固有の痛点がある:
1. 純粋なtry/catch
唯一の利点:何も新しく学ぶ必要がない。しかし代わりに——エラーに型がなく、重要な箇所でcatchを見落としやすく、複数のステップにわたるエラー処理が必要になるとコードはすぐにトレースが難しいカオスな状態になる。
2. Result/Either型(fp-ts、neverthrow)
核心的なアイデア:エラーをreturn typeにencodeする——Result<Value, Error>。コンパイラがproductionで爆発する前にエラー処理を促してくれる。try/catchより明確に優れている。しかし並行処理が必要な場合——3つのリクエストを並列実行して結果をまとめる——コードはすぐに複雑になる。
3. Promise + カスタムエラークラス
従来のJSチームでよく見かけるパターンは、型付きエラーでrejectすることだ:
class PaymentError extends Error {
constructor(public code: 'declined' | 'invalid_card', message: string) {
super(message);
}
}
// しかしPromise<T>はエラーをtype signatureにencodeしない
// TypeScriptは依然としてPaymentErrorのcatchを促さない
依然として「暗黙のエラー」——コンパイラはhandleを強制せず、後でコードを読む人もこの関数が何をthrowするか分からない。
4. Effect TS
Effectは3つの次元をtype signatureにencodeする:Effect<Success, Error, Requirements>。コンパイラはどんなエラーが発生し得るかを正確に把握し、処理を強制する。Concurrency、retry、timeout——すべてコアに統合されており、追加のライブラリは不要だ。
メリット・デメリット分析:Effect TSはあなたのプロジェクトに合っているか?
本当に重要なメリット
- Typed errors:
Effect<Order, NetworkError | PaymentError, never>— signatureを見ればどんなエラーが発生し得るかが即座にわかる。implementationを読む必要がない - Composability:EffectをLEGOのように繋げられ、各ステップでエラーを処理するか、制御しながらbubble upできる
- Concurrency built-in:
Effect.all、Effect.race、fiber-based scheduling——追加ライブラリ不要 - Resource safety:
Effect.acquireReleaseがエラーの有無に関わらずcleanupの実行を保証(GoのdeferやC#のusingと同じ考え方) - Testability:
Requirementsを通じたdependency injection——テストでdatabaseをmockするには一つのLayerを差し替えるだけ。複雑なmonkey-patchやjest.mockは不要
使うべきでない場合
- 小規模なスクリプト、one-offツール——Effectを学ぶコストに見合わない
- 関数型プログラミングに不慣れなチーム——最初の週のlearning curveはかなり急だ
- プロジェクトの締め切りが迫っている——スプリント中に新しいパラダイムを学ぶのは避けるべきだ
私がよく勧めるのは:core business logic(payment、注文処理)にはEffectを使い、エラーが起きても影響が少ない小さなutilityにはtry/catchを使い続けることだ。
適切なアプローチの選択
私がよく使う経験則:
- それぞれ異なる処理が必要な複数種類のエラーがあるロジック → Effect
- timeout/retryを伴うN個のタスクを並列実行する必要がある場合 → Effect
- シンプルなCRUD、単一レイヤー、edge caseのエラーが少ない → try/catchまたはneverthrowで十分
最近関わったWebアプリのプロジェクトには5人のdeveloperがいた。core business logicをEffectに移行した後、PRのレビュー時間が約40%削減された——reviewerはfunctionが何をthrowするか知るためにimplementationを読む必要がなくなった。2ヶ月後、未処理のエラーに起因するproduction bugはほぼゼロになった。
Effect TSの実践的な導入
インストール
npm install effect
# または
pnpm add effect
Effectは特別な設定は不要で、strict: trueのTypeScript 5.0+があれば動く。
型付きエラーハンドリング:unknownから具体的な型へ
最初のステップ——エラーをデータとして定義する:
import { Effect, Data } from 'effect';
// エラーをタグ付きデータとして定義
class NetworkError extends Data.TaggedError('NetworkError')<{
message: string;
statusCode: number;
}> {}
class PaymentDeclinedError extends Data.TaggedError('PaymentDeclinedError')<{
reason: 'insufficient_funds' | 'invalid_card' | 'expired';
}> {}
// Function signatureがすべてを語る
const fetchOrder = (id: string): Effect.Effect<Order, NetworkError> =>
Effect.tryPromise({
try: () => fetch(`/api/orders/${id}`).then(r => r.json()),
catch: (err) => new NetworkError({
message: String(err),
statusCode: 500
})
});
const chargeCard = (order: Order): Effect.Effect<Payment, NetworkError | PaymentDeclinedError> =>
Effect.tryPromise({
try: () => paymentGateway.charge(order),
catch: (err: any) => {
if (err.code === 'card_declined') {
return new PaymentDeclinedError({ reason: 'insufficient_funds' });
}
return new NetworkError({ message: err.message, statusCode: 500 });
}
});
chainと各ステップのエラー処理
const processOrder = (orderId: string) =>
fetchOrder(orderId).pipe(
Effect.flatMap(order => chargeCard(order)),
// ここではPaymentDeclinedErrorのみをhandle
Effect.catchTag('PaymentDeclinedError', (err) =>
Effect.logWarning(`Payment declined: ${err.reason}`).pipe(
Effect.flatMap(() => Effect.fail(err)) // log後に再throwする
)
),
// NetworkErrorは引き続きcallerにpropagate
);
// 最上位レイヤーで残りのすべてのエラーを処理
const main = processOrder('order-123').pipe(
Effect.catchAll((err) => {
// errはここで正確な型を持つ: NetworkError | PaymentDeclinedError
switch (err._tag) {
case 'NetworkError':
return Effect.logError(`Network issue: ${err.statusCode}`);
case 'PaymentDeclinedError':
return Effect.logError(`Payment declined: ${err.reason}`);
}
})
);
Effect.runPromise(main);
Concurrency:制御しながら並列実行
ここがEffectがPromise.allより明らかに優れていると感じる点だ:
import { Effect } from 'effect';
// 3つのタスクを並列実行、いずれかが失敗したら即座にfail
const parallel = Effect.all(
[
fetchUserProfile(userId),
fetchUserOrders(userId),
fetchUserSettings(userId),
],
{ concurrency: 'unbounded' } // または制限する: { concurrency: 2 }
);
// Race: 最速のソースから結果を取得
const fromCache = fetchFromCache(key);
const fromDB = fetchFromDatabase(key);
const result = Effect.race(fromCache, fromDB);
// Exponential backoffでリトライ
const withRetry = fetchOrder(id).pipe(
Effect.retry({
times: 3,
schedule: Schedule.exponential('100 millis')
})
);
// タイムアウト
const withTimeout = fetchOrder(id).pipe(
Effect.timeout('5 seconds')
);
リソース管理:cleanupを保証する
import { Effect } from 'effect';
// acquireReleaseはエラーの有無に関わらずconnectionが必ず閉じられることを保証
const withDbConnection = Effect.acquireRelease(
Effect.promise(() => pool.connect()), // 取得
(conn) => Effect.promise(() => conn.release()) // 解放 — 常に実行
);
const queryUser = (id: string) =>
Effect.scoped(
withDbConnection.pipe(
Effect.flatMap(conn =>
Effect.promise(() => conn.query('SELECT * FROM users WHERE id = $1', [id]))
)
)
);
実プロジェクトからの実践的なtips
- 一度にコードベース全体を変換しない:最も重要なモジュール(payment、auth)から始めて、徐々に拡張する。EffectはEffect.promise()とEffect.runPromise()を通じて通常のPromiseと十分な互換性がある。
- チームがpipe/flatMapに慣れていなければ
Effect.genを使う:async/awaitに近いsyntaxで、onboardingが容易:
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const order = yield* fetchOrder(orderId);
const payment = yield* chargeCard(order);
yield* sendConfirmationEmail(order, payment);
return { order, payment };
});
- dependency injectionにはLayer patternを使う:
Context.Tag+Layerを使ってinterfaceとimplementationを分離——テストにはmockをinject、productionには実実装をinject。別途DIフレームワークは不要。 - Effectのログを丁寧に確認する:Effectにはstructured logging(
Effect.log、Effect.logError)がbuilt-inで組み込まれている。console.logの代わりに使うことで、複数のlayerをまたいだエラーのトレースがずっと容易になる。
実用的な結論
Effect TSはすべての問題を解決するわけではない——しかしエラーを明確に処理する必要があり、concurrencyが必須となる本格的なTypeScriptプロジェクトにおいては、従来のtry/catchの最も痛い点を的確に解決する。Type signatureが生きたドキュメントになる——実装やコメントを読まなくても、functionがどんなエラーでfailし得るかが分かる。
最初の週は遅いだろう。3週目には、もう元のやり方には戻りたくなる。
