Effect Pattern完全ガイド — TypeScriptで関数型プログラミングとエラーハンドリングの新境地
Effect Patternは、TypeScriptで関数型プログラミングのパワーを活用し、型安全な非同期処理とエラーハンドリングを実現するデザインパターンです。Effect-TSライブラリを中心に、Result型、Railway Oriented Programming、依存性注入など、堅牢なアプリケーション開発のための実践的手法を解説します。
Effect Patternとは
Effect Patternは、副作用(Effect)を明示的に扱い、型システムで追跡することで、予測可能で保守性の高いコードを書くためのアプローチです。エラーハンドリング、依存性、非同期処理を型レベルで管理します。
主な概念
- Effect型 - 成功値、エラー、依存性を型パラメータで表現
- Railway Oriented Programming - エラーを別のレールとして扱う
- Result型 - 成功(Ok)と失敗(Err)を型安全に表現
- 依存性の明示化 - 必要な依存性を型で宣言
- 合成可能性 - 小さなEffectを組み合わせて大きな処理を構築
なぜEffect Patternなのか
// 従来のアプローチ
// ❌ エラーが型に現れない
// ❌ 例外が突然飛んでくる
// ❌ 依存性が隠れている
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
// Effect Pattern
// ✅ エラー型が明示的
// ✅ 依存性が型に現れる
// ✅ 例外なし、全て型安全
function getUser(id: string): Effect<User, UserNotFoundError, HttpClient> {
return Effect.gen(function* (_) {
const client = yield* _(HttpClient);
const response = yield* _(client.get(`/api/users/${id}`));
return response;
});
}
Effect-TSのインストール
npm install effect
基本的なインポート
import { Effect, pipe } from 'effect';
Effect型の基本
Effect型の構造
Effect<Success, Error, Requirements>
- Success - 成功時の値の型
- Error - 発生しうるエラーの型
- Requirements - 必要な依存性の型
基本的な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 sync = Effect.sync(() => {
console.log('Running...');
return 'Done';
});
// 非同期的な処理
const async = Effect.async<string>((resume) => {
setTimeout(() => {
resume(Effect.succeed('Async result'));
}, 1000);
});
// Promiseからの変換
const fromPromise = Effect.promise(() => fetch('/api/data').then((r) => r.json()));
Effectの実行
import { Effect } from 'effect';
const program = Effect.succeed('Hello Effect!');
// 実行
Effect.runPromise(program).then(console.log);
// => "Hello Effect!"
// 同期実行(エラーは例外として投げられる)
const result = Effect.runSync(Effect.succeed(42));
// => 42
// Exitとして取得(成功/失敗を含む)
Effect.runPromiseExit(program).then((exit) => {
if (exit._tag === 'Success') {
console.log('Success:', exit.value);
} else {
console.log('Failure:', exit.cause);
}
});
エラーハンドリング
基本的なエラー処理
import { Effect, pipe } from 'effect';
const riskyOperation = Effect.fail('Something went wrong');
// catchAllでエラー処理
const handled = pipe(
riskyOperation,
Effect.catchAll((error) => Effect.succeed(`Handled: ${error}`))
);
// catchTagで特定エラーのみ処理
class NetworkError {
readonly _tag = 'NetworkError';
constructor(readonly message: string) {}
}
class ValidationError {
readonly _tag = 'ValidationError';
constructor(readonly field: string) {}
}
const program = pipe(
Effect.fail(new NetworkError('Connection failed')),
Effect.catchTag('NetworkError', (error) => Effect.succeed(`Retry: ${error.message}`))
);
エラーのマッピング
const mapError = pipe(
Effect.fail('Raw error'),
Effect.mapError((error) => ({
code: 'ERROR_CODE',
message: error,
timestamp: Date.now(),
}))
);
フォールバック
const withFallback = pipe(
Effect.fail('Primary failed'),
Effect.orElse(() => Effect.succeed('Fallback value'))
);
// または複数のフォールバック
const withMultipleFallbacks = pipe(
Effect.fail('All failed'),
Effect.orElse(() => Effect.fail('Fallback 1 failed')),
Effect.orElse(() => Effect.succeed('Fallback 2 succeeded'))
);
Railway Oriented Programming
Result型の実装
type Result<T, E> = { _tag: 'Ok'; value: T } | { _tag: 'Err'; error: E };
const Ok = <T>(value: T): Result<T, never> => ({ _tag: 'Ok', value });
const Err = <E>(error: E): Result<never, E> => ({ _tag: 'Err', error });
// ヘルパー関数
const map = <T, U, E>(fn: (value: T) => U) => {
return (result: Result<T, E>): Result<U, E> => {
if (result._tag === 'Ok') {
return Ok(fn(result.value));
}
return result;
};
};
const flatMap = <T, U, E>(fn: (value: T) => Result<U, E>) => {
return (result: Result<T, E>): Result<U, E> => {
if (result._tag === 'Ok') {
return fn(result.value);
}
return result;
};
};
パイプライン構築
import { pipe } from 'effect';
type User = { id: string; name: string; email: string };
type ValidationError = { field: string; message: string };
const validateEmail = (email: string): Result<string, ValidationError> => {
if (!email.includes('@')) {
return Err({ field: 'email', message: 'Invalid email' });
}
return Ok(email);
};
const validateName = (name: string): Result<string, ValidationError> => {
if (name.length < 2) {
return Err({ field: 'name', message: 'Name too short' });
}
return Ok(name);
};
const createUser = (data: { name: string; email: string }): Result<User, ValidationError> => {
// Railway: エラーが起きたら以降の処理はスキップされる
const nameResult = validateName(data.name);
if (nameResult._tag === 'Err') return nameResult;
const emailResult = validateEmail(data.email);
if (emailResult._tag === 'Err') return emailResult;
return Ok({
id: crypto.randomUUID(),
name: nameResult.value,
email: emailResult.value,
});
};
Effect.genによる便利な記法
import { Effect } from 'effect';
// Generator構文でEffect合成
const program = Effect.gen(function* (_) {
// Effectを実行して値を取得
const x = yield* _(Effect.succeed(10));
const y = yield* _(Effect.succeed(20));
// 通常のJavaScriptのように書ける
const sum = x + y;
// エラーも自動で伝播
const result = yield* _(riskyOperation(sum));
return result;
});
実践例: ユーザー取得処理
class UserNotFoundError {
readonly _tag = 'UserNotFoundError';
constructor(readonly userId: string) {}
}
class NetworkError {
readonly _tag = 'NetworkError';
constructor(readonly message: string) {}
}
const fetchUser = (id: string) =>
Effect.gen(function* (_) {
// APIリクエスト
const response = yield* _(
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`),
catch: (error) => new NetworkError(String(error)),
})
);
// ステータスチェック
if (!response.ok) {
yield* _(Effect.fail(new UserNotFoundError(id)));
}
// JSON解析
const user = yield* _(
Effect.tryPromise({
try: () => response.json(),
catch: (error) => new NetworkError('Invalid JSON'),
})
);
return user;
});
// エラーハンドリング付きで実行
const program = pipe(
fetchUser('123'),
Effect.catchTag('UserNotFoundError', (error) =>
Effect.succeed({ id: error.userId, name: 'Guest', email: '' })
),
Effect.catchTag('NetworkError', (error) => Effect.fail(`Network issue: ${error.message}`))
);
依存性注入
Contextの定義
import { Context, Effect } from 'effect';
// サービスの定義
class Database extends Context.Tag('Database')<
Database,
{
readonly query: (sql: string) => Effect.Effect<any[], never, never>;
}
>() {}
class Logger extends Context.Tag('Logger')<
Logger,
{
readonly log: (message: string) => Effect.Effect<void, never, never>;
}
>() {}
依存性を使うEffect
const getUsers = Effect.gen(function* (_) {
const db = yield* _(Database);
const logger = yield* _(Logger);
yield* _(logger.log('Fetching users...'));
const users = yield* _(db.query('SELECT * FROM users'));
yield* _(logger.log(`Found ${users.length} users`));
return users;
});
// Effect<any[], never, Database | Logger>
依存性の提供
import { Layer } from 'effect';
// 実装の提供
const DatabaseLive = Layer.succeed(
Database,
Database.of({
query: (sql) =>
Effect.sync(() => {
console.log('Executing:', sql);
return [{ id: 1, name: 'Alice' }];
}),
})
);
const LoggerLive = Layer.succeed(
Logger,
Logger.of({
log: (message) =>
Effect.sync(() => {
console.log('[LOG]', message);
}),
})
);
// レイヤーを結合
const AppLayer = Layer.mergeAll(DatabaseLive, LoggerLive);
// 依存性を解決して実行
const program = pipe(getUsers, Effect.provide(AppLayer));
Effect.runPromise(program).then(console.log);
パターン: リトライと再試行
import { Effect, Schedule } from 'effect';
const unstableApi = Effect.gen(function* (_) {
const random = Math.random();
if (random < 0.7) {
yield* _(Effect.fail('API failed'));
}
return 'Success!';
});
// 再試行戦略
const withRetry = pipe(
unstableApi,
Effect.retry(
Schedule.exponential('100 millis').pipe(
Schedule.compose(Schedule.recurs(5)) // 最大5回
)
)
);
// タイムアウト付き
const withTimeout = pipe(unstableApi, Effect.timeout('5 seconds'));
// 両方組み合わせ
const robust = pipe(
unstableApi,
Effect.retry(Schedule.exponential('100 millis').pipe(Schedule.recurs(3))),
Effect.timeout('10 seconds')
);
パターン: 並列処理
import { Effect } from 'effect';
const task1 = Effect.succeed(1).pipe(Effect.delay('100 millis'));
const task2 = Effect.succeed(2).pipe(Effect.delay('200 millis'));
const task3 = Effect.succeed(3).pipe(Effect.delay('150 millis'));
// 全て並列実行
const allTasks = Effect.all([task1, task2, task3]);
// Effect<[number, number, number], never, never>
// オブジェクトとして実行
const taskObject = Effect.all({
first: task1,
second: task2,
third: task3,
});
// Effect<{ first: number, second: number, third: number }, never, never>
// 並列実行、最初に完了したものを取得
const raceResult = Effect.race(task1, task2);
// 最初に成功したものを取得(エラーは無視)
const firstSuccess = Effect.raceAll([task1, task2, task3]);
パターン: キャッシング
import { Effect, Cache, Duration } from 'effect';
// キャッシュ作成
const userCache = Cache.make({
capacity: 100,
timeToLive: Duration.minutes(5),
lookup: (userId: string) => fetchUser(userId),
});
// キャッシュ利用
const getUserCached = (id: string) =>
Effect.gen(function* (_) {
const cache = yield* _(userCache);
const user = yield* _(cache.get(id));
return user;
});
パターン: バッチ処理
import { Effect } from 'effect';
type UserId = string;
type User = { id: UserId; name: string };
const fetchUsersBatch = (ids: UserId[]): Effect.Effect<User[], never, never> =>
Effect.sync(() => {
console.log(`Batch fetching ${ids.length} users`);
return ids.map((id) => ({ id, name: `User ${id}` }));
});
// N+1問題を回避するバッチローダー
const getUserWithBatching = (id: UserId) =>
Effect.gen(function* (_) {
// 実際にはDataLoaderのようなバッチング機構を使用
const users = yield* _(fetchUsersBatch([id]));
return users[0];
});
パターン: トランザクション
import { Effect, STM } from 'effect';
// トランザクショナルメモリ
const transferMoney = (from: string, to: string, amount: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
// トランザクション開始
yield* _(db.beginTransaction());
try {
// 送金元から引き出し
yield* _(db.query(`UPDATE accounts SET balance = balance - ${amount} WHERE id = '${from}'`));
// 送金先へ入金
yield* _(db.query(`UPDATE accounts SET balance = balance + ${amount} WHERE id = '${to}'`));
// コミット
yield* _(db.commit());
return { success: true };
} catch (error) {
// ロールバック
yield* _(db.rollback());
yield* _(Effect.fail(error));
}
});
実践例: ユーザー登録フロー
import { Effect, pipe } from 'effect';
// エラー定義
class EmailAlreadyExistsError {
readonly _tag = 'EmailAlreadyExistsError';
constructor(readonly email: string) {}
}
class ValidationError {
readonly _tag = 'ValidationError';
constructor(readonly errors: Record<string, string>) {}
}
class DatabaseError {
readonly _tag = 'DatabaseError';
constructor(readonly message: string) {}
}
// 型定義
type CreateUserInput = {
name: string;
email: string;
password: string;
};
type User = {
id: string;
name: string;
email: string;
};
// バリデーション
const validateInput = (
input: CreateUserInput
): Effect.Effect<CreateUserInput, ValidationError, never> =>
Effect.gen(function* (_) {
const errors: Record<string, string> = {};
if (!input.name || input.name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!input.email.includes('@')) {
errors.email = 'Invalid email format';
}
if (input.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (Object.keys(errors).length > 0) {
yield* _(Effect.fail(new ValidationError(errors)));
}
return input;
});
// 重複チェック
const checkEmailUnique = (
email: string
): Effect.Effect<void, EmailAlreadyExistsError, Database> =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const existing = yield* _(db.query(`SELECT * FROM users WHERE email = '${email}'`));
if (existing.length > 0) {
yield* _(Effect.fail(new EmailAlreadyExistsError(email)));
}
});
// パスワードハッシュ化
const hashPassword = (password: string): Effect.Effect<string, never, never> =>
Effect.sync(() => {
// 実際にはbcryptなどを使用
return `hashed_${password}`;
});
// ユーザー作成
const createUserInDb = (
input: CreateUserInput & { hashedPassword: string }
): Effect.Effect<User, DatabaseError, Database> =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const result = yield* _(
Effect.tryPromise({
try: () =>
db.query(
`INSERT INTO users (name, email, password) VALUES ('${input.name}', '${input.email}', '${input.hashedPassword}')`
),
catch: (error) => new DatabaseError(String(error)),
})
);
return {
id: crypto.randomUUID(),
name: input.name,
email: input.email,
};
});
// メイン処理
const registerUser = (
input: CreateUserInput
): Effect.Effect<User, ValidationError | EmailAlreadyExistsError | DatabaseError, Database> =>
Effect.gen(function* (_) {
// バリデーション
const validInput = yield* _(validateInput(input));
// 重複チェック
yield* _(checkEmailUnique(validInput.email));
// パスワードハッシュ化
const hashedPassword = yield* _(hashPassword(validInput.password));
// ユーザー作成
const user = yield* _(createUserInDb({ ...validInput, hashedPassword }));
return user;
});
// エラーハンドリング付き実行
const safeRegisterUser = (input: CreateUserInput) =>
pipe(
registerUser(input),
Effect.catchTags({
ValidationError: (error) =>
Effect.succeed({
success: false,
errors: error.errors,
}),
EmailAlreadyExistsError: (error) =>
Effect.succeed({
success: false,
errors: { email: `Email ${error.email} already exists` },
}),
DatabaseError: (error) =>
Effect.succeed({
success: false,
errors: { _: 'Database error occurred' },
}),
}),
Effect.map((result) =>
'id' in result ? { success: true, user: result } : result
)
);
テスト
import { Effect, Layer } from 'effect';
// モックデータベース
const MockDatabase = Layer.succeed(
Database,
Database.of({
query: (sql) =>
Effect.sync(() => {
if (sql.includes('SELECT')) {
return []; // ユーザーなし
}
return [{ id: 1 }]; // INSERT成功
}),
})
);
// テスト
const testRegisterUser = Effect.gen(function* (_) {
const result = yield* _(
registerUser({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
})
);
console.assert(result.name === 'Alice');
console.assert(result.email === 'alice@example.com');
});
// モックを提供して実行
Effect.runPromise(pipe(testRegisterUser, Effect.provide(MockDatabase)));
まとめ
Effect Patternは、TypeScriptで型安全かつ堅牢なアプリケーションを構築するための強力なアプローチです。
主な利点:
- エラーが型に現れ、見落としがない
- 依存性が明示的で、テストが容易
- 合成可能で保守性が高い
- Railway Oriented Programmingでエラー処理が明確
- 非同期処理が同期的に書ける
こんなプロジェクトに最適:
- 堅牢性が求められるバックエンドAPI
- 複雑なビジネスロジック
- 依存性管理が重要なアプリケーション
- 型安全性を最大限活用したい
Effect-TSは学習曲線がありますが、習得すればTypeScriptで最高レベルの型安全性と保守性を実現できます。