Effect-TSで型安全なエラーハンドリング - 完全実装ガイド


Effect-TSは、TypeScriptで型安全に副作用を扱うための革新的なライブラリです。従来のPromiseasync/awaitでは実現できなかった、エラー型の完全な追跡と依存性注入を提供します。

本記事では、Effect-TSの基礎から実践的な実装パターン、本番環境での活用方法まで詳しく解説します。

Effect-TSとは

Effect-TSは、関数型プログラミングの概念をTypeScriptに持ち込み、副作用を一級市民として扱うライブラリです。ScalaのZIOやHaskellのIOモナドに影響を受けています。

従来の問題点

// 従来のPromise: エラー型が失われる
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error('Failed to fetch user'); // どんなエラーかわからない
  }
  return response.json();
}

// 呼び出し側
try {
  const user = await fetchUser('123');
  console.log(user.name);
} catch (error) {
  // errorは unknown、型安全性ゼロ
  console.error(error);
}

Effect-TSでの解決

import { Effect, pipe } from 'effect';

// エラー型が明示される
type FetchError =
  | { _tag: 'NetworkError'; cause: Error }
  | { _tag: 'NotFoundError'; id: string }
  | { _tag: 'ParseError'; body: string };

function fetchUser(id: string): Effect.Effect<User, FetchError, never> {
  return pipe(
    Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: (error) => ({ _tag: 'NetworkError' as const, cause: error as Error }),
    }),
    Effect.flatMap(response =>
      response.ok
        ? Effect.tryPromise({
            try: () => response.json(),
            catch: (error) => ({ _tag: 'ParseError' as const, body: String(error) }),
          })
        : response.status === 404
          ? Effect.fail({ _tag: 'NotFoundError' as const, id })
          : Effect.fail({ _tag: 'NetworkError' as const, cause: new Error(`HTTP ${response.status}`) })
    ),
  );
}

// 呼び出し側: エラーが型で保証される
const program = pipe(
  fetchUser('123'),
  Effect.match({
    onFailure: (error) => {
      // errorの型は FetchError
      switch (error._tag) {
        case 'NetworkError':
          console.error('Network error:', error.cause);
          break;
        case 'NotFoundError':
          console.error('User not found:', error.id);
          break;
        case 'ParseError':
          console.error('Parse error:', error.body);
          break;
      }
    },
    onSuccess: (user) => {
      console.log('User:', user.name);
    },
  })
);

Effect.runPromise(program);

セットアップ

インストール

npm install effect

基本概念

Effect-TSの型シグネチャは3つのパラメータを持ちます。

Effect.Effect<Success, Error, Requirements>

// Success: 成功時の値の型
// Error: 失敗時のエラーの型
// Requirements: 実行に必要な依存関係の型
// 例
Effect.Effect<User, FetchError, Database>
// - 成功したら User を返す
// - 失敗したら FetchError を返す
// - 実行には Database サービスが必要

基本的な使い方

Effectの作成

import { Effect } from 'effect';

// 成功するEffect
const success = Effect.succeed(42);
// Effect<number, never, never>

// 失敗するEffect
const failure = Effect.fail('Something went wrong');
// Effect<never, string, never>

// 同期的な計算
const computation = Effect.sync(() => {
  console.log('Running computation');
  return Math.random();
});
// Effect<number, never, never>

// 非同期的な計算
const asyncComputation = Effect.promise(() => {
  return fetch('/api/data').then(res => res.json());
});
// Effect<unknown, never, never>

Effectの合成

import { pipe } from 'effect';

// map: 成功値を変換
const doubled = pipe(
  Effect.succeed(10),
  Effect.map(x => x * 2)
);
// Effect<number, never, never> (20)

// flatMap: Effectを連鎖
const program = pipe(
  Effect.succeed(5),
  Effect.flatMap(x => Effect.succeed(x * 2)),
  Effect.flatMap(x => Effect.succeed(x + 3))
);
// Effect<number, never, never> (13)

// mapError: エラーを変換
const withMappedError = pipe(
  Effect.fail('low level error'),
  Effect.mapError(err => ({ _tag: 'AppError' as const, message: err }))
);

エラーハンドリング

// エラーをキャッチして回復
const recovered = pipe(
  Effect.fail('error'),
  Effect.catchAll((error) => Effect.succeed('default value'))
);
// Effect<string, never, never>

// 特定のエラーのみキャッチ
type MyError =
  | { _tag: 'NotFound' }
  | { _tag: 'Unauthorized' }
  | { _tag: 'ServerError' };

const selective = pipe(
  fetchData(),
  Effect.catchTag('NotFound', () => Effect.succeed(null)),
  Effect.catchTag('Unauthorized', () => Effect.fail('Please login'))
);

// フォールバック
const withFallback = pipe(
  fetchFromPrimary(),
  Effect.orElse(() => fetchFromSecondary()),
  Effect.orElse(() => fetchFromCache())
);

実践パターン

パターン1: APIクライアントの実装

// api-client.ts
import { Effect, pipe } from 'effect';
import { Schema } from '@effect/schema';

// エラー型の定義
export class NetworkError {
  readonly _tag = 'NetworkError';
  constructor(readonly cause: Error) {}
}

export class ValidationError {
  readonly _tag = 'ValidationError';
  constructor(readonly errors: unknown) {}
}

export class HttpError {
  readonly _tag = 'HttpError';
  constructor(readonly status: number, readonly body: string) {}
}

// ユーザースキーマ
const UserSchema = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  email: Schema.String,
});

type User = Schema.Schema.Type<typeof UserSchema>;

// API クライアント
export class ApiClient {
  constructor(private baseUrl: string) {}

  // GETリクエスト
  get<A>(
    path: string,
    schema: Schema.Schema<A>
  ): Effect.Effect<A, NetworkError | ValidationError | HttpError, never> {
    return pipe(
      Effect.tryPromise({
        try: () => fetch(`${this.baseUrl}${path}`),
        catch: (error) => new NetworkError(error as Error),
      }),
      Effect.flatMap((response) =>
        response.ok
          ? Effect.tryPromise({
              try: () => response.json(),
              catch: (error) => new NetworkError(error as Error),
            })
          : Effect.fail(new HttpError(response.status, response.statusText))
      ),
      Effect.flatMap((data) =>
        Schema.decodeUnknown(schema)(data).pipe(
          Effect.mapError((error) => new ValidationError(error))
        )
      )
    );
  }

  // POSTリクエスト
  post<A, B>(
    path: string,
    body: A,
    schema: Schema.Schema<B>
  ): Effect.Effect<B, NetworkError | ValidationError | HttpError, never> {
    return pipe(
      Effect.tryPromise({
        try: () =>
          fetch(`${this.baseUrl}${path}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
          }),
        catch: (error) => new NetworkError(error as Error),
      }),
      Effect.flatMap((response) =>
        response.ok
          ? Effect.tryPromise({
              try: () => response.json(),
              catch: (error) => new NetworkError(error as Error),
            })
          : Effect.fail(new HttpError(response.status, response.statusText))
      ),
      Effect.flatMap((data) =>
        Schema.decodeUnknown(schema)(data).pipe(
          Effect.mapError((error) => new ValidationError(error))
        )
      )
    );
  }
}

// 使用例
const client = new ApiClient('https://api.example.com');

const fetchUser = (id: string) =>
  pipe(
    client.get(`/users/${id}`, UserSchema),
    Effect.retry({ times: 3 }),
    Effect.timeout('5 seconds'),
    Effect.catchTag('HttpError', (error) =>
      error.status === 404
        ? Effect.succeed(null)
        : Effect.fail(error)
    )
  );

パターン2: 依存性注入

// services.ts
import { Context, Effect, Layer } from 'effect';

// データベースサービスの定義
export class Database extends Context.Tag('Database')<
  Database,
  {
    query: <A>(sql: string, params: unknown[]) => Effect.Effect<A, Error, never>;
  }
>() {}

// ロガーサービスの定義
export class Logger extends Context.Tag('Logger')<
  Logger,
  {
    info: (message: string) => Effect.Effect<void, never, never>;
    error: (message: string) => Effect.Effect<void, never, never>;
  }
>() {}

// データベース実装
const DatabaseLive = Layer.succeed(
  Database,
  {
    query: <A>(sql: string, params: unknown[]) =>
      Effect.tryPromise({
        try: async () => {
          // 実際のデータベースクエリ
          const result = await pool.query(sql, params);
          return result.rows as A;
        },
        catch: (error) => error as Error,
      }),
  }
);

// ロガー実装
const LoggerLive = Layer.succeed(
  Logger,
  {
    info: (message) =>
      Effect.sync(() => {
        console.log(`[INFO] ${message}`);
      }),
    error: (message) =>
      Effect.sync(() => {
        console.error(`[ERROR] ${message}`);
      }),
  }
);

// ユーザーリポジトリ
export const UserRepository = {
  findById: (id: string) =>
    Effect.gen(function* (_) {
      const db = yield* _(Database);
      const logger = yield* _(Logger);

      yield* _(logger.info(`Fetching user: ${id}`));

      const users = yield* _(
        db.query<User>('SELECT * FROM users WHERE id = $1', [id])
      );

      if (users.length === 0) {
        yield* _(logger.error(`User not found: ${id}`));
        return yield* _(Effect.fail({ _tag: 'NotFound' as const }));
      }

      return users[0];
    }),

  create: (data: { name: string; email: string }) =>
    Effect.gen(function* (_) {
      const db = yield* _(Database);
      const logger = yield* _(Logger);

      yield* _(logger.info(`Creating user: ${data.email}`));

      const users = yield* _(
        db.query<User>(
          'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
          [data.name, data.email]
        )
      );

      return users[0];
    }),
};

// アプリケーションの構築
const AppLayer = Layer.mergeAll(DatabaseLive, LoggerLive);

const program = pipe(
  UserRepository.findById('123'),
  Effect.flatMap((user) =>
    Effect.gen(function* (_) {
      const logger = yield* _(Logger);
      yield* _(logger.info(`Found user: ${user.name}`));
      return user;
    })
  )
);

// 実行
const runnable = Effect.provide(program, AppLayer);
Effect.runPromise(runnable);

パターン3: 並行処理とリトライ

// parallel.ts
import { Effect, pipe } from 'effect';

// 複数のAPIを並行で呼び出し
const fetchUserProfile = (userId: string) =>
  Effect.all(
    {
      user: fetchUser(userId),
      posts: fetchUserPosts(userId),
      followers: fetchFollowers(userId),
    },
    { concurrency: 'unbounded' } // 並行実行
  );

// リトライ戦略
const fetchWithRetry = (url: string) =>
  pipe(
    Effect.tryPromise({
      try: () => fetch(url),
      catch: (error) => new NetworkError(error as Error),
    }),
    Effect.retry({
      times: 3,
      schedule: Schedule.exponential('100 millis'),
    }),
    Effect.timeout('10 seconds')
  );

// レースコンディション(最初に成功したものを使用)
const fetchFromMultipleSources = pipe(
  Effect.race(
    fetchFromPrimary(),
    Effect.race(fetchFromSecondary(), fetchFromTertiary())
  )
);

// バッチ処理
const processBatch = (items: string[]) =>
  Effect.all(
    items.map((item) => processItem(item)),
    { concurrency: 5 } // 最大5並行
  );

パターン4: リソース管理

// resource.ts
import { Effect, pipe } from 'effect';

// データベース接続のリソース管理
const withDatabase = <A, E>(
  f: (db: Database) => Effect.Effect<A, E, never>
): Effect.Effect<A, E, never> =>
  Effect.acquireUseRelease(
    // acquire
    Effect.sync(() => {
      console.log('Opening database connection');
      return createDatabaseConnection();
    }),
    // use
    (db) => f(db),
    // release
    (db) =>
      Effect.sync(() => {
        console.log('Closing database connection');
        db.close();
      })
  );

// 使用例
const queryUsers = withDatabase((db) =>
  Effect.tryPromise({
    try: () => db.query('SELECT * FROM users'),
    catch: (error) => error as Error,
  })
);

// ファイル処理
const processFile = (path: string) =>
  Effect.acquireUseRelease(
    Effect.tryPromise({
      try: () => fs.promises.open(path, 'r'),
      catch: (error) => error as Error,
    }),
    (file) =>
      Effect.tryPromise({
        try: () => file.readFile({ encoding: 'utf-8' }),
        catch: (error) => error as Error,
      }),
    (file) =>
      Effect.sync(() => {
        file.close();
      })
  );

テスティング

ユニットテスト

// user.service.test.ts
import { Effect, Layer } from 'effect';
import { describe, it, expect } from 'vitest';

// モックデータベース
const MockDatabase = Layer.succeed(Database, {
  query: (sql, params) =>
    Effect.succeed([
      { id: '1', name: 'Alice', email: 'alice@example.com' },
    ]),
});

// モックロガー
const MockLogger = Layer.succeed(Logger, {
  info: (message) => Effect.void,
  error: (message) => Effect.void,
});

const MockAppLayer = Layer.mergeAll(MockDatabase, MockLogger);

describe('UserRepository', () => {
  it('should find user by id', async () => {
    const program = UserRepository.findById('1');
    const result = await Effect.runPromise(
      Effect.provide(program, MockAppLayer)
    );

    expect(result.name).toBe('Alice');
  });

  it('should fail when user not found', async () => {
    const EmptyDatabase = Layer.succeed(Database, {
      query: () => Effect.succeed([]),
    });

    const program = UserRepository.findById('999');

    await expect(
      Effect.runPromise(
        Effect.provide(program, Layer.mergeAll(EmptyDatabase, MockLogger))
      )
    ).rejects.toEqual({ _tag: 'NotFound' });
  });
});

統合テスト

// integration.test.ts
import { Effect } from 'effect';
import { describe, it, beforeAll, afterAll } from 'vitest';

describe('Integration Tests', () => {
  let container: TestContainer;

  beforeAll(async () => {
    container = await new GenericContainer('postgres:16')
      .withExposedPorts(5432)
      .start();

    // マイグレーション実行
    await runMigrations();
  });

  afterAll(async () => {
    await container.stop();
  });

  it('should create and fetch user', async () => {
    const program = Effect.gen(function* (_) {
      const newUser = yield* _(
        UserRepository.create({
          name: 'Bob',
          email: 'bob@example.com',
        })
      );

      const fetchedUser = yield* _(UserRepository.findById(newUser.id));

      return fetchedUser;
    });

    const result = await Effect.runPromise(
      Effect.provide(program, RealAppLayer)
    );

    expect(result.name).toBe('Bob');
  });
});

パフォーマンス最適化

メモ化

import { Effect, Cache } from 'effect';

// 計算結果のキャッシュ
const cache = Cache.make({
  capacity: 100,
  timeToLive: '1 hour',
  lookup: (key: string) => expensiveComputation(key),
});

const getCachedResult = (key: string) =>
  Effect.flatMap(cache, (c) => Cache.get(c, key));

バッチ処理の最適化

// リクエストのバッチング
const batchedFetch = RequestResolver.makeBatched(
  (requests: Array<{ id: string }>) =>
    Effect.tryPromise({
      try: async () => {
        const ids = requests.map((r) => r.id);
        const users = await fetchUsersBatch(ids);
        return users;
      },
      catch: (error) => error as Error,
    })
);

まとめ

Effect-TSは、TypeScriptで型安全な副作用管理を実現する強力なライブラリです。

主な利点

  • 完全な型安全性: エラー型を含めて完全に型で保証
  • 合成可能性: 小さなEffectを組み合わせて複雑なロジックを構築
  • 依存性注入: 型安全なDIが標準装備
  • リソース管理: 自動的なクリーンアップを保証
  • 並行処理: 並列実行、リトライ、タイムアウトを簡単に扱える

適用領域

  • バックエンドAPI: エラーハンドリングが重要なシステム
  • マイクロサービス: 複雑な依存関係の管理
  • データパイプライン: リソースの適切な管理
  • CLI ツール: 型安全な副作用の制御

学習リソース

Effect-TSを活用することで、エラーハンドリングが型レベルで保証され、バグの少ない堅牢なアプリケーションを構築できます。特にエンタープライズアプリケーションや、信頼性が重要なシステムに最適です。