TypeScript Effect システム:Effect-TSで副作用をエレガントに処理する
なぜ Effect-TS が TypeScript のエラー処理を革新しているのか
TypeScript プロジェクトにおいて、非同期エラー処理は常に課題でした。try/catch は非同期エラーをキャッチできず、Promise の .catch() は見落としやすく、async/await はエラー処理を暗黙的にします。Effect-TS は副作用を値としてモデル化することで、この状況を根本的に変えました。
| 特徴 | Effect-TS | Promise/async-await | fp-ts | neverthrow |
|---|---|---|---|---|
| 型安全なエラー | ✅ 完全に型付け | ❌ unknown | ✅ 完全に型付け | ✅ 完全に型付け |
| 依存性注入 | ✅ 組み込み Layer | ❌ なし | ❌ 手動必要 | ❌ なし |
| 並行プリミティブ | ✅ Fiber | ❌ ネイティブなし | ❌ なし | ❌ なし |
| リトライ/タイムアウト | ✅ 組み込み | ❌ 手動 | ❌ 手動 | ❌ 手動 |
| 合成可能性 | ✅ 高い | ⚠️ 中程度 | ✅ 高い | ⚠️ 中程度 |
| 学習曲線 | ⚠️ 急 | ✅ 低い | ❌ 急 | ✅ 低い |
| エコシステム成熟度 | ✅ 活発 | ✅ ネイティブ | ⚠️ メンテナンスモード | ⚠️ ニッチ |
| リソース解放 | ✅ Scope | ❌ finally | ❌ なし | ❌ なし |
Effect-TS は 2026 年、TypeScript の関数型プログラミングにおけるデファクトスタンダードとなりました。その核心思想は:副作用をデータとして記述し、即時実行しない。これにより、プログラムはテスト可能、合成可能、推論可能になります。
Effect:コアコンセプトと基本操作
Effect は Effect-TS のコア抽象であり、失敗する可能性のある計算を記述します。「強化版 Promise」と考えてください — ただし、遅延評価で、型安全で、合成可能です。
Effect の作成
import { Effect } from "effect";
// 同期値から作成
const syncEffect = Effect.succeed(42);
// 例外を投げる可能性のある同期コードから作成
const riskySync = Effect.sync(() => {
const data = JSON.parse('{"name":"effect"}');
return data;
});
// 非同期操作から作成
const asyncEffect = Effect.tryPromise({
try: () => fetch("https://api.example.com/users"),
catch: (error) => new FetchError(error),
});
// カスタムエラー型の定義
class FetchError {
readonly _tag = "FetchError";
constructor(readonly cause: unknown) {}
}
class ParseError {
readonly _tag = "ParseError";
constructor(readonly cause: unknown) {}
}
チェーン操作とマッピング
import { Effect } from "effect";
const program = Effect.gen(function* (_) {
const response = yield* _(
Effect.tryPromise({
try: () => fetch("https://api.example.com/users"),
catch: (error) => new FetchError(error),
})
);
const data = yield* _(
Effect.try({
try: () => response.json(),
catch: (error) => new ParseError(error),
})
);
return data;
});
// マッピングとチェーン操作
const transformed = pipe(
Effect.succeed(10),
Effect.map((n) => n * 2),
Effect.flatMap((n) => Effect.succeed(n + 5))
);
Effect の実行
import { Effect } from "effect";
const program = Effect.succeed("Hello, Effect-TS!");
// Node.js で実行
Effect.runPromise(program).then(console.log);
// 出力: Hello, Effect-TS!
// 同期実行(同期 Effect のみ)
Effect.runSync(Effect.succeed(42));
// 出力: 42
// コールバックで実行
Effect.runCallback(program, {
onExit: (exit) => {
if (exit._tag === "Success") {
console.log(exit.value);
} else {
console.error(exit.cause);
}
},
});
Layer:依存性注入システム
Layer は Effect-TS の依存性注入メカニズムであり、サービスの作成と使用を完全に分離できます。これはテストとモジュール化において非常に強力です。
import { Effect, Layer, Context } from "effect";
// サービスインターフェースの定義
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown[], DbError>;
readonly connect: () => Effect.Effect<void, DbError>;
}
>() {}
class DbError {
readonly _tag = "DbError";
constructor(readonly message: string) {}
}
// PostgreSQL Layer の実装
const PostgresLive = Layer.succeed(Database, {
query: (sql: string) =>
Effect.tryPromise({
try: () => pgPool.query(sql),
catch: (error) => new DbError(String(error)),
}),
connect: () =>
Effect.tryPromise({
try: () => pgPool.connect(),
catch: (error) => new DbError(String(error)),
}),
});
// Mock Layer(テスト用)
const DatabaseMock = Layer.succeed(Database, {
query: (sql: string) => Effect.succeed([{ id: 1, name: "test" }]),
connect: () => Effect.succeed(void 0),
});
// サービスの使用
const getUserById = (id: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const rows = yield* _(db.query(`SELECT * FROM users WHERE id = ${id}`));
return rows[0];
});
// 本番環境はリアル Layer を使用
const program = getUserById(1).pipe(
Effect.provide(PostgresLive)
);
// テスト環境は Mock Layer を使用
const testProgram = getUserById(1).pipe(
Effect.provide(DatabaseMock)
);
Service:サービス定義と合成
Service は Effect-TS で注入可能なサービスを定義する標準パターンで、Context.Tag を通じて実装されます。
import { Effect, Context, Layer } from "effect";
// Logger サービスの定義
class Logger extends Context.Tag("Logger")<
Logger,
{
readonly info: (msg: string) => Effect.Effect<void>;
readonly error: (msg: string) => Effect.Effect<void>;
}
>() {}
// Config サービスの定義
class AppConfig extends Context.Tag("AppConfig")<
AppConfig,
{
readonly apiUrl: string;
readonly timeout: number;
}
>() {}
// Logger の実装
const LoggerLive = Layer.succeed(Logger, {
info: (msg) => Effect.sync(() => console.log(`[INFO] ${msg}`)),
error: (msg) => Effect.sync(() => console.error(`[ERROR] ${msg}`)),
});
// Config の実装
const ConfigLive = Layer.succeed(AppConfig, {
apiUrl: "https://api.example.com",
timeout: 5000,
});
// 複数サービスの合成
const AppLive = Layer.merge(LoggerLive, ConfigLive);
// 複数サービスを使用するビジネスロジック
const fetchWithLogging = Effect.gen(function* (_) {
const logger = yield* _(Logger);
const config = yield* _(AppConfig);
yield* _(logger.info(`Fetching from ${config.apiUrl}`));
const result = yield* _(
Effect.tryPromise({
try: () => fetch(config.apiUrl),
catch: (error) => new FetchError(error),
})
);
yield* _(logger.info("Fetch completed"));
return result;
}).pipe(Effect.provide(AppLive));
Fiber:並行プログラミング
Fiber は Effect-TS の軽量並行ユニットで、コルーチンに似ていますがより強力で、中断、監視、構造化並行をサポートしています。
import { Effect, Fiber } from "effect";
// 複数の Effect を並列実行
const parallelExample = Effect.gen(function* (_) {
const [users, posts, comments] = yield* _(
Effect.all(
[
Effect.tryPromise({
try: () => fetch("/api/users"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/posts"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/comments"),
catch: (e) => new FetchError(e),
}),
],
{ concurrency: "unbounded" }
)
);
return { users, posts, comments };
});
// レース:最速の結果を取得
const raceExample = Effect.gen(function* (_) {
const result = yield* _(
Effect.race(
Effect.tryPromise({
try: () => fetch("/api/cdn1/data"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/cdn2/data"),
catch: (e) => new FetchError(e),
})
)
);
return result;
});
// 手動 Fiber 制御
const fiberExample = Effect.gen(function* (_) {
const fiber = yield* _(
Effect.fork(
Effect.tryPromise({
try: () => longRunningTask(),
catch: (e) => new TaskError(e),
})
)
);
// 他の処理...
yield* _(Effect.sleep("2 seconds"));
// Fiber の完了を待機
const result = yield* _(Fiber.join(fiber));
return result;
});
// 構造化並行:親 Effect 終了時に子 Fiber が自動中断
const structuredConcurrency = Effect.gen(function* (_) {
yield* _(
Effect.fork(
Effect.gen(function* (_) {
yield* _(Effect.sleep("10 seconds"));
console.log("これは実行されない(親 Effect が先に終了するため)");
})
)
);
yield* _(Effect.sleep("1 second"));
// 親 Effect 終了、子 Fiber は自動中断
});
コールバックから Promise へ、そして Effect へ:完全移行例
コールバック地獄の時代
function getUserCallback(id: number, callback: (err: Error | null, user?: User) => void) {
db.query("SELECT * FROM users WHERE id = ?", [id], (err, rows) => {
if (err) return callback(err);
if (rows.length === 0) return callback(new Error("User not found"));
callback(null, rows[0]);
});
}
function getOrdersCallback(userId: number, callback: (err: Error | null, orders?: Order[]) => void) {
db.query("SELECT * FROM orders WHERE userId = ?", [userId], (err, rows) => {
if (err) return callback(err);
callback(null, rows);
});
}
// 使用:コールバック地獄
getUserCallback(1, (err, user) => {
if (err) { console.error(err); return; }
getOrdersCallback(user!.id, (err, orders) => {
if (err) { console.error(err); return; }
console.log({ user, orders });
});
});
Promise/async-await の時代
async function getUser(id: number): Promise<User> {
const rows = await db.query("SELECT * FROM users WHERE id = ?", [id]);
if (rows.length === 0) throw new Error("User not found");
return rows[0];
}
async function getOrders(userId: number): Promise<Order[]> {
return db.query("SELECT * FROM orders WHERE userId = ?", [userId]);
}
// 使用:エラー型が失われる
async function main() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id);
console.log({ user, orders });
} catch (error) {
// error は unknown、ユーザー不在とDBエラーを区別できない
console.error(error);
}
}
Effect-TS の時代
class UserNotFoundError {
readonly _tag = "UserNotFoundError";
constructor(readonly userId: number) {}
}
class DbQueryError {
readonly _tag = "DbQueryError";
constructor(readonly message: string, readonly cause: unknown) {}
}
const getUser = (id: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const rows = yield* _(
db.query("SELECT * FROM users WHERE id = ?", [id]).pipe(
Effect.mapError((e) => new DbQueryError("Query failed", e))
)
);
if (rows.length === 0) {
return yield* _(Effect.fail(new UserNotFoundError(id)));
}
return rows[0] as User;
});
const getOrders = (userId: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
return yield* _(db.query("SELECT * FROM orders WHERE userId = ?", [userId]));
});
// 使用:完全に型安全なエラー処理
const program = Effect.gen(function* (_) {
const user = yield* _(getUser(1));
const orders = yield* _(getOrders(user.id));
return { user, orders };
}).pipe(
Effect.catchTag("UserNotFoundError", (e) =>
Effect.succeed({ error: `ユーザー ${e.userId} が見つかりません` })
),
Effect.catchTag("DbQueryError", (e) =>
Effect.succeed({ error: `データベースエラー: ${e.message}` })
)
);
エラー処理パターン
catchAll:すべてのエラーをキャッチ
import { Effect } from "effect";
const resilient = pipe(
riskyOperation(),
Effect.catchAll((error) =>
Effect.succeed(`エラーから復旧: ${String(error)}`)
)
);
catchTag:型ごとにエラーをキャッチ
import { Effect } from "effect";
const taggedHandler = pipe(
program,
Effect.catchTag("FetchError", (e) =>
Effect.succeed({ fallback: true, reason: e.cause })
),
Effect.catchTag("ParseError", (e) =>
Effect.succeed({ fallback: true, reason: e.cause })
)
);
retry:指数バックオフ
import { Effect, Schedule } from "effect";
const retryWithBackoff = pipe(
fetchFromApi(),
Effect.retry(
Schedule.exponential("1 second").pipe(
Schedule.compose(Schedule.recurs(5)),
Schedule.tap((attempt) =>
Effect.sync(() => console.log(`リトライ ${attempt} 回目...`))
)
)
)
);
// 条件付きリトライ
const conditionalRetry = pipe(
fetchFromApi(),
Effect.retry(
Schedule.exponential("500 millis").pipe(
Schedule.whileOutput((delay) => delay < 30_000),
Schedule.whileInput((error) => error._tag === "NetworkError")
)
)
);
timeout:タイムアウト制御
import { Effect } from "effect";
class TimeoutError {
readonly _tag = "TimeoutError";
}
const withTimeout = pipe(
longRunningOperation(),
Effect.timeout("5 seconds"),
Effect.mapError(() => new TimeoutError())
);
// タイムアウトとフォールバック
const withTimeoutFallback = pipe(
longRunningOperation(),
Effect.timeoutFail({
duration: "3 seconds",
onTimeout: () => new TimeoutError(),
}),
Effect.catchTag("TimeoutError", () =>
Effect.succeed(getCachedResult())
)
);
依存性注入の実践:完全な例
import { Effect, Context, Layer } from "effect";
// エラー型の定義
class HttpError {
readonly _tag = "HttpError";
constructor(readonly status: number, readonly message: string) {}
}
// HttpClient サービスの定義
class HttpClient extends Context.Tag("HttpClient")<
HttpClient,
{
readonly get: (url: string) => Effect.Effect<Response, HttpError>;
readonly post: (url: string, body: unknown) => Effect.Effect<Response, HttpError>;
}
>() {}
// Cache サービスの定義
class Cache extends Context.Tag("Cache")<
Cache,
{
readonly get: (key: string) => Effect.Effect<unknown | null>;
readonly set: (key: string, value: unknown, ttl: number) => Effect.Effect<void>;
}
>() {}
// リアル HttpClient 実装
const HttpClientLive = Layer.succeed(HttpClient, {
get: (url) =>
Effect.tryPromise({
try: () => fetch(url),
catch: (error) => new HttpError(0, String(error)),
}).pipe(
Effect.flatMap((res) =>
res.ok
? Effect.succeed(res)
: Effect.fail(new HttpError(res.status, res.statusText))
)
),
post: (url, body) =>
Effect.tryPromise({
try: () =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
catch: (error) => new HttpError(0, String(error)),
}).pipe(
Effect.flatMap((res) =>
res.ok
? Effect.succeed(res)
: Effect.fail(new HttpError(res.status, res.statusText))
)
),
});
// インメモリ Cache 実装
const InMemoryCacheLive = Layer.effect(
Cache,
Effect.sync(() => {
const store = new Map<string, { value: unknown; expires: number }>();
return {
get: (key) =>
Effect.sync(() => {
const entry = store.get(key);
if (!entry || entry.expires < Date.now()) return null;
return entry.value;
}),
set: (key, value, ttl) =>
Effect.sync(() => {
store.set(key, { value, expires: Date.now() + ttl });
}),
};
})
);
// ビジネスロジック:キャッシュ付き API 呼び出し
const fetchWithCache = (url: string) =>
Effect.gen(function* (_) {
const cache = yield* _(Cache);
const http = yield* _(HttpClient);
const cached = yield* _(cache.get(url));
if (cached) return cached;
const response = yield* _(http.get(url));
const data = yield* _(
Effect.try({
try: () => response.json(),
catch: (e) => new HttpError(0, `Parse error: ${String(e)}`),
})
);
yield* _(cache.set(url, data, 60_000));
return data;
});
// アプリケーションの組み立て
const AppLayer = Layer.merge(HttpClientLive, InMemoryCacheLive);
const main = fetchWithCache("https://api.example.com/data").pipe(
Effect.provide(AppLayer),
Effect.catchTag("HttpError", (e) =>
Effect.succeed({ error: `HTTP ${e.status}: ${e.message}` })
)
);
並行の実践:完全な Fiber 例
import { Effect, Fiber, Ref, Queue } from "effect";
// プロデューサー・コンシューマーパターン
const producerConsumer = Effect.gen(function* (_) {
const queue = yield* _(Queue.bounded<string>(10));
const producer = Effect.gen(function* (_) {
for (let i = 0; i < 5; i++) {
yield* _(Queue.offer(queue, `メッセージ-${i}`));
yield* _(Effect.sleep("100 millis"));
}
yield* _(Queue.offer(queue, "DONE"));
});
const consumer = Effect.gen(function* (_) {
while (true) {
const msg = yield* _(Queue.take(queue));
if (msg === "DONE") break;
console.log(`消費: ${msg}`);
}
});
yield* _(
Effect.all([producer, consumer], { concurrency: 2 })
);
});
// レート制限付き並列リクエスト
const fetchWithConcurrencyLimit = (urls: string[]) =>
Effect.gen(function* (_) {
const semaphore = yield* _(Effect.makeSemaphore(3));
const results = yield* _(
Effect.all(
urls.map((url) =>
semaphore.withPermits(1)(
Effect.tryPromise({
try: () => fetch(url),
catch: (e) => new FetchError(e),
})
)
),
{ concurrency: "unbounded" }
)
);
return results;
});
// Fiber 監視
const supervisedExample = Effect.gen(function* (_) {
const fiber = yield* _(
Effect.fork(
Effect.gen(function* (_) {
yield* _(Effect.sleep("5 seconds"));
return "完了";
})
)
);
yield* _(
Fiber.interrupt(fiber).pipe(
Effect.race(Fiber.join(fiber))
)
);
});
5 つのよくある落とし穴と解決策
1. Effect の実行忘れ
// ❌ 間違い:Effect は遅延評価、実行しないと動作しない
const program = Effect.succeed(42).pipe(Effect.map(console.log));
// ✅ 正しい:明示的に実行する必要がある
Effect.runPromise(program);
2. Effect 内での Promise の混用
// ❌ 間違い:async/await は Effect のエラートラッキングを破壊する
const bad = Effect.async(() => {
const data = await fetch("/api"); // 構文エラー
});
// ✅ 正しい:Effect.tryPromise でラップ
const good = Effect.tryPromise({
try: () => fetch("/api"),
catch: (e) => new FetchError(e),
});
3. タグなしのエラー型
// ❌ 間違い:生の Error は catchTag を使えない
const bad = Effect.fail(new Error("something went wrong"));
// ✅ 正しい:_tag 付きカスタムエラーを使用
class AppError {
readonly _tag = "AppError";
constructor(readonly message: string) {}
}
const good = Effect.fail(new AppError("something went wrong"));
4. Layer の提供忘れ
// ❌ 間違い:Database を使用しているが Layer が提供されていない
const program = Effect.gen(function* (_) {
const db = yield* _(Database);
return yield* _(db.query("SELECT 1"));
});
Effect.runPromise(program); // 実行時エラー:Database の Layer が不足
// ✅ 正しい:Layer を提供する
Effect.runPromise(program.pipe(Effect.provide(PostgresLive)));
5. Fiber の中断を無視
// ❌ 間違い:中断不可能な Effect は Fiber のクリーンアップを妨げる
const bad = Effect.sync(() => {
while (true) {} // 無限ループ、中断できない
});
// ✅ 正しい:Effect.yieldNow で制御を譲渡
const good = Effect.gen(function* (_) {
while (true) {
yield* _(Effect.yieldNow());
}
});
10 のエラートラブルシューティング項目
| # | エラー現象 | 原因 | 解決策 |
|---|---|---|---|
| 1 | Error: Service not found: Database |
Layer が提供されていない | Effect.provide() で Layer を注入 |
| 2 | Uncaught TypeError: Effect is not a function |
インポートパスの間違い | import { Effect } from "effect" を確認 |
| 3 | Effect が実行されない | Effect.runPromise 等の呼び出し忘れ |
Effect は遅延評価、明示的な実行が必要 |
| 4 | catchTag がエラーにマッチしない |
エラークラスに _tag プロパティがない |
カスタムエラーに readonly _tag を追加 |
| 5 | 型推論が失敗し never になる |
エラー型と catch が一致しない | Effect のエラー型パラメータを確認 |
| 6 | Fiber interrupted が予期せず発生 |
親 Fiber 終了による子 Fiber の中断 | Effect.forkDaemon でデーモン Fiber を作成 |
| 7 | メモリリーク | Fiber が適切にクリーンアップされていない | 構造化並行を使用、forkDaemon の乱用を避ける |
| 8 | Schedule リトライが動作しない |
リトライ条件を満たしていない | whileInput / whileOutput 条件を確認 |
| 9 | Layer の循環依存 | 2 つの Layer が相互依存 | 依存関係をリファクタリング、Layer.provide で分割 |
| 10 | timeout が動作しない |
タイムアウト時間の単位エラー | 数値ではなく文字列形式 "5 seconds" を使用 |
Effect-TS vs Promise/async-await 比較
| 次元 | Effect-TS | Promise/async-await |
|---|---|---|
| エラー型 | コンパイル時に完全に型付け | unknown、手動アサーションが必要 |
| 遅延評価 | ✅ 遅延実行 | ❌ 即時実行 |
| 合成可能性 | ✅ pipe + gen | ⚠️ 手動ラップが必要 |
| 依存性注入 | ✅ Layer システム | ❵ 組み込みソリューションなし |
| 並行制御 | ✅ Fiber + Semaphore | ❌ ネイティブサポートなし |
| リトライ戦略 | ✅ Schedule | ❌ 手動実装 |
| タイムアウト制御 | ✅ 組み込み | ❌ Promise.race ハック |
| リソース安全性 | ✅ Scope + acquireRelease | ⚠️ finally は保証されない |
| 中断サポート | ✅ Fiber.interrupt | ❌ AbortController |
| テスト容易性 | ✅ Layer を差し替えるだけ | ⚠️ モジュールモックが必要 |
| デバッグ体験 | ⚠️ 深いスタックトレース | ✅ クリアなネイティブスタック |
| バンドルサイズ | ⚠️ ~80KB min | ✅ 0KB(ネイティブ) |
おすすめツール
Effect-TS 開発で生産性を向上させるオンラインツール:
- JSON フォーマッター — API レスポンスのデバッグ時に JSON を素早くフォーマット・検証
- Base64 エンコード/デコード — HTTP ヘッダーとトークンエンコーディングの処理
- ハッシュ計算ツール — データ署名とキャッシュキーのハッシュ生成
まとめ:Effect-TS は副作用を値としてモデル化することで、TypeScript にかつてない型安全性と合成可能性をもたらします。Effect、Layer、Service、Fiber の 4 つのコアコンセプトをマスターし、catchTag、retry、timeout などのエラー処理パターンを組み合わせれば、2026 年に真に堅牢な関数型 TypeScript コードを書くことができます。学習曲線は急ですが、一度マスターすれば、生の Promise に戻ることは二度とありません。
ブラウザローカルツールを無料で試す →