error-handling-idioms
リポジトリ: Effect-TS/effect 分析日: 2026-02-18
概要
Effect-TS は「ロスレスなエラーモデル」を設計原則とし、エラー情報を一切捨てない型安全なエラーチャネルを構築している。Effect<A, E, R> の E パラメータにより、発生しうるエラーの型が関数シグネチャに刻まれ、Cause データ型が期待エラー(Fail)・予期せぬ欠陥(Die)・中断(Interrupt)・並列/逐次合成を構造化して保持する。この視点では、エラーの分類体系、タグベースのディスパッチ、エラー境界の設計パターン、そしてリカバリ戦略を横断的に分析し、他の型安全言語・フレームワークに応用可能なプラクティスを抽出する。
背景にある原則
エラー情報は決して捨てない(Lossless Error Model): 複数のエラーが並列・逐次に発生した場合でも、
Causeのツリー構造(Parallel,Sequential)で全情報を保持する。一般的なtry-catchでは最初の例外だけが残り後続が失われるが、Effect-TS は全経路のエラーを構造化して保存する。根拠:Cause.ts:1-22のモジュールドキュメントに「lossless error model」と明記されている。期待エラーと欠陥を型レベルで分離する:
Fail<E>は回復可能なドメインエラー、Dieは回復不能なバグや予期せぬ例外として分離される。Die.defectはunknown型で型追跡されず、Fail.errorはE型で追跡される。この分離により「何が回復可能で何がバグか」がコンパイル時に判別できる。根拠:Cause.ts:460-497のFailとDieの型定義。エラー型はタグで識別し、型レベルで絞り込む: エラー型に
_tagリテラル型を持たせることで、catchTagが型安全にエラーをディスパッチし、ハンドル済みのエラーを型から除去する。これはパターンマッチの TypeScript 実装であり、エラーの網羅性チェックを型システムに委ねる。根拠:Effect.ts:3882-3890のcatchTag型シグネチャでExclude<E, { _tag: K }>によりハンドル済みエラーが型から消える。エラー境界は明示的に宣言する:
orDieで期待エラーを欠陥に変換する操作、sandboxで欠陥を期待エラーに引き上げる操作が対称的に提供されている。境界の位置を開発者が意図的に選ぶことで、「ここから先は回復しない」という設計判断がコードに表現される。根拠:Effect.ts:11265のorDieとEffect.ts:4246のsandbox。
実例と分析
Cause のツリー構造による並列エラー保持
Cause は6つのバリアントから成る再帰的なデータ型で、エラーの発生パターンを構造として保持する:
// packages/effect/src/Cause.ts:254-260
export type Cause<E> =
| Empty
| Fail<E>
| Die
| Interrupt
| Sequential<E>
| Parallel<E>;Parallel は並行実行で複数エラーが同時発生した場合に両方を保持し、Sequential はメイン処理の失敗とファイナライザの失敗を連鎖保持する。CauseReducer インタフェース(Cause.ts:296-303)により、この再帰構造を任意の型に畳み込める。
TaggedError による型安全なエラー定義
Data.TaggedError と Schema.TaggedError の2つのエラー定義パターンがコードベース全体で一貫して使われている:
// packages/sql/src/Migrator.ts:57-67
export class MigrationError extends Data.TaggedError("MigrationError")<{
readonly _tag: "MigrationError";
readonly cause?: unknown;
readonly reason:
| "bad-state"
| "import-error"
| "failed"
| "duplicates"
| "locked";
readonly message: string;
}> {}// packages/platform/src/Multipart.ts:145-160
export class MultipartError extends Schema.TaggedError<MultipartError>()("MultipartError", {
reason: Schema.Literal("FileTooLarge", "FieldTooLarge", "BodyTooLarge", "TooManyParts", "InternalError", "Parse"),
cause: Schema.Defect,
}) {
readonly [ErrorTypeId]: ErrorTypeId = ErrorTypeId;
get message(): string {
return this.reason;
}
}Data.TaggedError は軽量な定義向け、Schema.TaggedError はシリアライズ境界を超える場合(RPC、永続化)に使われる。どちらも _tag フィールドを自動付与し、YieldableError を継承するため yield* でそのまま Effect に変換できる。
reason フィールドによるサブ分類パターン
エラー型に reason フィールドを持たせ、同一 _tag 内でエラーの原因を文字列リテラルユニオンで細分化するパターンがコードベース全体で統一されている:
// packages/platform/src/HttpClientError.ts:38-43
export class RequestError extends Error.TypeIdError(TypeId, "RequestError")<{
readonly request: ClientRequest.HttpClientRequest
readonly reason: "Transport" | "Encode" | "InvalidUrl"
readonly cause?: unknown
readonly description?: string
}> { ... }// packages/platform/src/Socket.ts:140-143
export class SocketGenericError extends TypeIdError(SocketErrorTypeId, "SocketError")<{
readonly reason: "Write" | "Read" | "Open" | "OpenTimeout"
readonly cause: unknown
}> { ... }_tag がエラーの「種類」、reason がエラーの「原因」を表す二層分類。_tag は catchTag でディスパッチし、reason は catchTag 後のハンドラ内で分岐する。
catchTag による型安全エラーディスパッチ
// packages/platform/src/Multipart.ts:551-554
}).pipe(
Effect.catchTags({
SystemError: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause })),
BadArgument: (cause) => Effect.fail(new MultipartError({ reason: "InternalError", cause }))
})
)catchTags は複数のエラー型を一括でハンドルし、型レベルで処理済みエラーを除去する。上記では SystemError | BadArgument が MultipartError に変換され、呼び出し側のエラー型から消える。
エラー境界パターン: catchTag + die による不正状態の昇格
// packages/workflow/src/DurableQueue.ts:204-208
} as any, { id }).pipe(
Effect.tapErrorCause(Effect.logWarning),
Effect.catchTag("ParseError", Effect.die),
Effect.retry(options?.retrySchedule ?? defaultRetrySchedule),
Effect.orDie,ParseError はデータ不整合を示すためリトライしても意味がなく、即座に die で欠陥に昇格させる。その後の retry は ParseError 以外のエラー(ネットワーク障害等)に対してのみ適用される。最終的に orDie で残りのエラーも欠陥化し、型を Effect<A, never, R> にする。
エラー翻訳パターン: mapError による境界変換
// packages/sql/src/SqlEventJournal.ts:158-161
entries: sql`SELECT * FROM ${sql(entryTable)} ORDER BY timestamp ASC`.withoutTransform.pipe(
Effect.flatMap(decodeEntrySqlArray),
Effect.mapError((cause) => new EventJournal.EventJournalError({ cause, method: "entries" }))
),低レベルの SqlError を高レベルの EventJournalError にラップする。cause フィールドに元のエラーを保持することで情報のロスレス性を維持しつつ、呼び出し側には抽象化されたエラー型のみを公開する。このパターンは SqlEventJournal.ts 全体で7箇所に適用されている。
Effect.try による同期例外のキャプチャ
// packages/platform/src/MsgPack.ts:70-72
Effect.try({
try: () => Chunk.of(packr.pack(Chunk.toReadonlyArray(input))),
catch: (cause) => new MsgPackError({ reason: "Pack", cause })
}),Effect.try は同期的な例外をキャッチして型付きエラーに変換する。catch コールバックで具体的なエラー型を生成するため、unknown が型チャネルに漏れることを防ぐ。
message getter パターン
エラークラスに get message() を定義し、構造化されたフィールドから人間向けメッセージを生成するパターンが統一されている:
// packages/platform/src/HttpClientError.ts:48-52
get message() {
return this.description ?
`${this.reason}: ${this.description} (${this.methodAndUrl})` :
`${this.reason} error (${this.methodAndUrl})`
}パターンカタログ
Discriminated Union (分類: 構造)
- 解決する問題: エラー型のパターンマッチと網羅性チェック
- 適用条件: 複数のエラー型を統一的にハンドルする場合
- コード例:
Cause.ts:254-260(Cause 型)、HttpServerError.ts:31(HttpServerError 型) - 注意点:
_tagフィールドは文字列リテラル型でなければならない
Composite パターン (分類: 構造)
- 解決する問題: 並列・逐次で発生する複数エラーの情報保持
- 適用条件: 並行処理やファイナライザでエラーが複合発生する場合
- コード例:
Cause.ts:535-560(Parallel,Sequential) - 注意点: ツリーの深さが増すため、
reduceWithContextで畳み込む仕組みが必要
Adapter パターン (分類: 構造)
- 解決する問題: 低レベルエラーを高レベルのドメインエラーに変換
- 適用条件: モジュール境界でエラーの抽象度を揃える場合
- コード例:
SqlEventJournal.ts:160、Multipart.ts:551-554 - 注意点: 元のエラーを
causeフィールドに保持しないと情報が失われる
Good Patterns
- Tag + Reason 二層分類:
_tagでエラーの種類を識別し、reasonで原因を細分化する。_tagは型レベルのディスパッチに使い、reasonはハンドラ内のロジック分岐に使う。エラー型の増殖を防ぎつつ十分な表現力を維持する。
// packages/platform/src/HttpClientError.ts:38-43
class RequestError extends Error.TypeIdError(TypeId, "RequestError")<{
readonly reason: "Transport" | "Encode" | "InvalidUrl"
readonly cause?: unknown
readonly description?: string
}>- YieldableError による generator 統合:
YieldableErrorを継承したエラーはcommit()メソッドでEffect.fail(this)を返すため、yield*でそのまま失敗 Effect として使える。エラーの生成と発出が一体化し、ボイラープレートが減る。
// packages/effect/src/internal/core.ts:2230-2234
class YieldableError extends globalThis.Error {
commit() {
return fail(this);
}
}- tapErrorCause + catchTag + retry の組み合わせ: エラーをログに記録(
tapErrorCause)→ 回復不能なエラーを欠陥に昇格(catchTag+die)→ 残りをリトライ(retry)→ 最終的に欠陥化(orDie)という段階的エラーハンドリングチェーン。
// packages/workflow/src/DurableQueue.ts:205-208
Effect.tapErrorCause(Effect.logWarning),
Effect.catchTag("ParseError", Effect.die),
Effect.retry(options?.retrySchedule ?? defaultRetrySchedule),
Effect.orDie,Anti-Patterns / 注意点
- catch なしの Effect.try:
Effect.tryにcatchを渡さないとUnknownExceptionが返り、型チャネルがunknown相当になる。エラーの型安全性が失われる。
// Bad: 型が Effect<A, UnknownException>
const bad = Effect.try(() => JSON.parse(input));
// Better: 具体的なエラー型を生成
const better = Effect.try({
try: () => JSON.parse(input),
catch: (cause) => new ParseError({ cause }),
});- 安易な orDie の多用:
orDieは期待エラーを欠陥に変換し型から消すが、エラー情報自体はスタックトレースに残るだけで構造的にアクセスできなくなる。モジュール内部ではなくモジュール境界で意図的に使うべき。
// Bad: ドメインロジック内で orDie
const bad = fetchUser(id).pipe(Effect.orDie);
// Better: 境界で mapError してから必要なら orDie
const better = fetchUser(id).pipe(
Effect.mapError((e) => new AppError({ cause: e })),
);- reason フィールドの文字列リテラル型を省略:
reasonをstring型にすると型安全なパターンマッチができない。リテラルユニオンにすべき。
// Bad
class MyError {
readonly reason: string;
}
// Better
class MyError {
readonly reason: "NotFound" | "Timeout" | "Unauthorized";
}導出ルール
[MUST]エラー型には_tagフィールドをリテラル型で定義し、discriminated union として型安全にパターンマッチできるようにする- 根拠: Effect-TS の全エラー型(
MigrationError,HttpClientError,MultipartError等 20+ クラス)が_tagをリテラル型で持ち、catchTag/catchTagsによる型安全ディスパッチを実現している
- 根拠: Effect-TS の全エラー型(
[MUST]エラーを変換・ラップする際は元のエラーをcauseフィールドに保持し、エラーチェーンを途切れさせない- 根拠:
SqlEventJournal.tsの全エラー変換箇所(7箇所)でnew EventJournalError({ cause, method })として元エラーを保持しており、Multipart.ts:552でも同様
- 根拠:
[SHOULD]回復可能なエラー(ドメインエラー)と回復不能なエラー(バグ・不正状態)を型レベルで分離し、回復不能エラーは明示的にdie/throwで別チャネルに移す- 根拠:
DurableQueue.ts:206でParseError(データ不整合)をdieで欠陥に昇格させ、リトライ対象から除外している。CauseのFail/Die分離が型レベルでこの設計を強制している
- 根拠:
[SHOULD]エラー型にreasonフィールドをリテラルユニオン型で持たせ、同一エラー種別内の原因を構造的に分類する- 根拠:
HttpClientError.RequestError("Transport" | "Encode" | "InvalidUrl")、MultipartError(6 種類の reason)など、コードベース全体で_tag+reasonの二層分類が統一されている
- 根拠:
[SHOULD]モジュール境界でエラー型を変換し、内部実装のエラー型を呼び出し側に漏洩させない- 根拠:
SqlEventJournalは内部のSqlErrorをEventJournalErrorにラップし、MultipartはSystemError | BadArgumentをMultipartErrorに変換して公開 API のエラー型を制御している
- 根拠:
[SHOULD]エラークラスにget message()getter を定義し、構造化フィールド(reason,method,description)から人間向けメッセージを合成する- 根拠:
HttpClientError.ts:48-52、Error.ts:133-137、AiError.ts:247-278など全パッケージのエラークラスが統一的にこのパターンを実装している
- 根拠:
[AVOID]例外をキャッチしてunknown型のまま型チャネルに流す。必ず具体的なエラー型に変換してから失敗させる- 根拠:
Effect.tryのcatchなし呼び出しはUnknownExceptionを返すが、コードベースではMsgPack.ts:70-72、httpClient.ts:211など全箇所でcatchを指定して具体型に変換している
- 根拠:
適用チェックリスト
- [ ] プロジェクトのエラー型に
_tagリテラル型フィールドが定義されているか - [ ] エラー型が discriminated union として定義され、網羅的なパターンマッチが可能か
- [ ] エラーをラップ・変換する箇所で元のエラーが
causeフィールドに保持されているか - [ ] 回復可能なエラーと回復不能なエラー(バグ)が明確に分離されているか
- [ ] モジュール境界で内部エラー型が外部に漏洩していないか(エラー翻訳が行われているか)
- [ ] 外部 API やパーサーの例外キャッチ時に、
unknownのまま放置せず具体的なエラー型に変換しているか - [ ] エラークラスに
messagegetter があり、構造化フィールドから人間向けメッセージを生成しているか - [ ] 同一エラー型内で原因のバリエーションがある場合、
reasonフィールドでリテラルユニオンにより分類されているか