fp-refactor について
TypeScript の命令型コードを fp-ts の関数型パターンへ移行する開発者向けガイドです。エラー、null 値、非同期処理、依存関係、ループの変換と段階的な導入を扱います。
主な機能
- 01try-catch から Either/TaskEither への変換
- 02null 値を Option で表現
- 03コールバックや Promise を Task に変換
- 04クラスベース DI を Reader に置換
- 05ループを関数型操作に書き換え
- 06段階的な導入とよくある落とし穴
活用シーン
- 01例外を関数型で明示する場合
- 02失敗する非同期処理を合成する場合
- 03Reader でサービス依存関係を管理する場合
- 04既存の TypeScript コードを段階的に改修する場合
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill fp-refactorインストール後、Claude Code で/fp-refactorを入力して読み込まれたか確認します。
詳細
命令型コードを fp-ts にリファクタリングする
このスキルでは、既存の命令型 TypeScript コードを fp-ts の関数型プログラミングパターンに移行するための包括的なパターンと戦略を紹介します。
目次
- try-catch を Either/TaskEither に変換する
- null チェックを Option に変換する
- コールバックを Task に変換する
- クラスベースの DI を Reader に変換する
- 命令型ループを関数型操作に変換する
- Promise チェーンを TaskEither に移行する
- よくある落とし穴
- 段階的な導入戦略
- リファクタリングしないほうがよい場合
1. try-catch を Either/TaskEither に変換する
try-catch の問題点
従来の try-catch ブロックには、いくつかの問題があります。
- エラー処理が暗黙的で、忘れやすいです
- 型システムでは、どの関数が例外をスローする可能性があるか追跡できません
- 制御フローが非線形で、推論しにくくなります
- 失敗する可能性のある複数の操作を合成するには冗長になります
パターン:同期 try-catch を Either に変換する
変換前(命令型)
function parseJSON(input: string): unknown {
try {
return JSON.parse(input);
} catch (error) {
throw new Error(`Invalid JSON: ${error}`);
}
}
function validateUser(data: unknown): User {
try {
if (!data || typeof data !== 'object') {
throw new Error('Data must be an object');
}
const obj = data as Record<string, unknown>;
if (typeof obj.name !== 'string') {
throw new Error('Name is required');
}
if (typeof obj.age !== 'number') {
throw new Error('Age must be a number');
}
return { name: obj.name, age: obj.age };
} catch (error) {
throw error;
}
}
// Usage with nested try-catch
function processUserInput(input: string): User | null {
try {
const data = parseJSON(input);
const user = validateUser(data);
return user;
} catch (error) {
console.error('Failed to process user:', error);
return null;
}
}
変換後(fp-ts Either)
import * as E from 'fp-ts/Either';
import * as J from 'fp-ts/Json';
import { pipe } from 'fp-ts/function';
interface User {
name: string;
age: number;
}
// Use Json.parse which returns Either<Error, Json>
const parseJSON = (input: string): E.Either<Error, unknown> =>
pipe(
J.parse(input),
E.mapLeft((e) => new Error(`Invalid JSON: ${e}`))
);
// Validation returns Either, making errors explicit in types
const validateUser = (data: unknown): E.Either<Error, User> => {
if (!data || typeof data !== 'object') {
return E.left(new Error('Data must be an object'));
}
const obj = data as Record<string, unknown>;
if (typeof obj.name !== 'string') {
return E.left(new Error('Name is required'));
}
if (typeof obj.age !== 'number') {
return E.left(new Error('Age must be a number'));
}
return E.right({ name: obj.name, age: obj.age });
};
// Compose with pipe and flatMap - errors propagate automatically
const processUserInput = (input: string): E.Either<Error, User> =>
pipe(
parseJSON(input),
E.flatMap(validateUser)
);
// Handle both cases explicitly
pipe(
processUserInput('{"name": "Alice", "age": 30}'),
E.match(
(error) => console.error('Failed to process user:', error.message),
(user) => console.log('User:', user)
)
);
リファクタリングの手順
- エラー型を特定する:発生し得るエラーを判断し、適切なエラー型を作成します
- 戻り値の型を変更する:
Tから、Eがエラー型となるEither<E, T>に変更します - throw 文を置き換える:
throw new Error(...)をE.left(new Error(...))に変換します - return 文を置き換える:
return valueをE.right(value)に変換します - try-catch ブロックを削除する:不要になります
- 呼び出し元を更新する:
pipeとE.flatMapを使って操作を連結します
パターン:非同期 try-catch を TaskEither に変換する
変換前(命令型)
async function fetchUser(id: string): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
return validateUser(data);
} catch (error) {
throw new Error(`Failed to fetch user: ${error}`);
}
}
async function fetchUserPosts(userId: string): Promise<Post[]> {
try {
const response = await fetch(`/api/users/${userId}/posts`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
throw new Error(`Failed to fetch posts: ${error}`);
}
}
// Complex orchestration with try-catch
async function getUserWithPosts(id: string): Promise<{ user: User; posts: Post[] } | null> {
try {
const user = await fetchUser(id);
const posts = await fetchUserPosts(id);
return { user, posts };
} catch (error) {
console.error(error);
return null;
}
}
変換後(fp-ts TaskEither)
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
// Wrap fetch in TaskEither
const fetchUser = (id: string): TE.TaskEither<Error, User> =>
pipe(
TE.tryCatch(
() => fetch(`/api/users/${id}`),
(reason) => new Error(`Network error: ${reason}`)
),
TE.flatMap((response) =>
response.ok
? TE.right(response)
: TE.left(new Error(`HTTP error: ${response.status}`))
),
TE.flatMap((response) =>
TE.tryCatch(
() => response.json(),
(reason) => new Error(`JSON parse error: ${reason}`)
)
),
TE.flatMap((data) => TE.fromEither(validateUser(data)))
);
const fetchUserPosts = (userId: string): TE.TaskEither<Error, Post[]> =>
pipe(
TE.tryCatch(
() => fetch(`/api/users/${userId}/posts`),
(reason) => new Error(`Network error: ${reason}`)
),
TE.flatMap((response) =>
response.ok
? TE.right(response)
: TE.left(new Error(`HTTP error: ${response.status}`))
),
TE.flatMap((response) =>
TE.tryCatch(
() => response.json(),
(reason) => new Error(`JSON parse error: ${reason}`)
)
)
);
// Clean composition with automatic error propagation
const getUserWithPosts = (
id: string
): TE.TaskEither<Error, { user: User; posts: Post[] }> =>
pipe(
TE.Do,
TE.bind('user', () => fetchUser(id)),
TE.bind('posts', () => fetchUserPosts(id))
);
// Execute and handle results
const main = async () => {
const result = await getUserWithPosts('123')();
pipe(
result,
E.match(
(error) => console.error('Failed:', error.message),
({ user, posts }) => console.log('Success:', user, posts)
)
);
};
ヘルパー:tryCatch ユーティリティ
例外をスローする可能性がある関数のために、再利用可能なラッパーを作成します。
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
// For sync functions
const tryCatchSync = <A>(f: () => A): E.Either<Error, A> =>
E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e))));
// For async functions
const tryCatchAsync = <A>(f: () => Promise<A>): TE.TaskEither<Error, A> =>
TE.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e))));
2. null チェックを Option に変換する
null/undefined の問題点
- TypeScript の厳密な null チェックは役立ちますが、それでも null はコード全体に広がります
- プロパティへの連鎖アクセスには、冗長な null ガードが必要です
- 「値がない」ことと「値が存在するが null である」ことの区別が曖昧です
- null チェックを忘れやすく、実行時エラーにつながります
パターン:単純な null チェックを Option に変換する
変換前(命令型)
interface Config {
database?: {
host?: string;
port?: number;
credentials?: {
username?: string;
password?: string;
};
};
}
function getDatabaseUrl(config: Config): string | null {
if (!config.database) {
return null;
}
if (!config.database.host) {
return null;
}
const port = config.database.port ?? 5432;
let auth = '';
if (config.database.credentials) {
if (config.database.credentials.username && config.database.credentials.password) {
auth = `${config.database.credentials.username}:${config.database.credentials.password}@`;
}
}
return `postgres://${auth}${config.database.host}:${port}`;
}
// Usage requires null check
const url = getDatabaseUrl(config);
if (url !== null) {
connectToDatabase(url);
} else {
console.error('Database URL not configured');
}
変換後(fp-ts Option)
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
const getDatabaseUrl = (config: Config): O.Option<string> =>
pipe(
O.fromNullable(config.database),
O.flatMap((db) =>
pipe(
O.fromNullable(db.host),
O.map((host) => {
const port = db.port ?? 5432;
const auth = pipe(
O.fromNullable(db.credentials),
O.flatMap((creds) =>
pipe(
O.Do,
O.bind('username', () => O.fromNullable(creds.username)),
O.bind('password', () => O.fromNullable(creds.password)),
O.map(({ username, password }) => `${username}:${password}@`)
)
),
O.getOrElse(() => '')
);
return `postgres://${auth}${host}:${port}`;
})
)
)
);
// Usage is explicit about the optional nature
pipe(
getDatabaseUrl(config),
O.match(
() => console.error('Database URL not configured'),
(url) => connectToDatabase(url)
)
);
パターン:配列の検索操作
変換前(命令型)
interface User {
id: string;
name: string;
email: string;
}
function findUserById(users: User[], id: string): User | undefined {
return users.find((u) => u.id === id);
}
function getUserEmail(users: User[], id: string): string | null {
const user = findUserById(users, id);
if (!user) {
return null;
}
return user.email;
}
// Chained lookups get messy
function getManagerEmail(users: User[], employee: { managerId?: string }): string | null {
if (!employee.managerId) {
return null;
}
const manager = findUserById(users, employee.managerId);
if (!manager) {
return null;
}
return manager.email;
}
変換後(fp-ts Option)
import * as O from 'fp-ts/Option';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
const findUserById = (users: User[], id: string): O.Option<User> =>
A.findFirst<User>((u) => u.id === id)(users);
const getUserEmail = (users: User[], id: string): O.Option<string> =>
pipe(
findUserById(users, id),
O.map((user) => user.email)
);
const getManagerEmail = (
users: User[],
employee: { managerId?: string }
): O.Option<string> =>
pipe(
O.fromNullable(employee.managerId),
O.flatMap((managerId) => findUserById(users, managerId)),
O.map((manager) => manager.email)
);
リファクタリングの手順
- null 許容値を特定する:
T | null、T | undefined、またはオプショナルプロパティをすべて見つけます - fromNullable でラップする:システム境界で null 許容値を Option に変換します
- 戻り値の型を変更する:
T | nullからOption<T>に変更します - null チェックを置き換える:if 文の代わりに
O.map、O.flatMap、O.filterを使います - 境界で処理する:fp-ts 以外のコードと連携するときは、
O.getOrElse、O.match、またはO.toNullableを使います
Option と Either の間で変換する
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
// Option to Either: provide error for None case
const optionToEither = <E, A>(onNone: () => E) => (
option: O.Option<A>
): E.Either<E, A> =>
pipe(
option,
E.fromOption(onNone)
);
// Example
const findUser = (id: string): O.Option<User> => /* ... */;
const getUser = (id: string): E.Either<Error, User> =>
pipe(
findUser(id),
E.fromOption(() => new Error(`User ${id} not found`))
);
3. コールバックを Task に変換する
コールバックの問題点
- コールバック地獄によってコードが読みづらくなります
- エラー処理に一貫性がありません
- 合成や順序付けが困難です
- 非同期操作を扱う標準的な方法がありません
パターン:Node スタイルのコールバックを Task に変換する
変換前(命令型)
import * as fs from 'fs';
function readFileCallback(
path: string,
callback: (error: Error | null, data: string | null) => void
): void {
fs.readFile(path, 'utf-8', (err, data) => {
if (err) {
callback(err, null);
} else {
callback(null, data);
}
});
}
function processFile(
inputPath: string,
outputPath: string,
callback: (error: Error | null) => void
): void {
readFileCallback(inputPath, (err, data) => {
if (err) {
callback(err);
return;
}
const processed = data!.toUpperCase();
fs.writeFile(outputPath, processed, (writeErr) => {
if (writeErr) {
callback(writeErr);
} else {
callback(null);
}
});
});
}
// Callback hell
function processMultipleFiles(
files: Array<{ input: string; output: string }>,
callback: (error: Error | null) => void
): void {
let completed = 0;
let hasError = false;
files.forEach(({ input, output }) => {
if (hasError) return;
processFile(input, output, (err) => {
if (hasError) return;
if (err) {
hasError = true;
callback(err);
return;
}
completed++;
if (completed === files.length) {
callback(null);
}
});
});
}
変換後(fp-ts Task/TaskEither)
import * as fs from 'fs/promises';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
// Wrap fs.promises in TaskEither
const readFile = (path: string): TE.TaskEither<Error, string> =>
TE.tryCatch(
() => fs.readFile(path, 'utf-8'),
(e) => (e instanceof Error ? e : new Error(String(e)))
);
const writeFile = (path: string, data: string): TE.TaskEither<Error, void> =>
TE.tryCatch(
() => fs.writeFile(path, data),
(e) => (e instanceof Error ? e : new Error(String(e)))
);
// Clean composition
const processFile = (
inputPath: string,
outputPath: string
): TE.TaskEither<Error, void> =>
pipe(
readFile(inputPath),
TE.map((data) => data.toUpperCase()),
TE.flatMap((processed) => writeFile(outputPath, processed))
);
// Process multiple files in parallel or sequence
const processMultipleFilesParallel = (
files: Array<{ input: string; output: string }>
): TE.TaskEither<Error, void[]> =>
pipe(
files,
A.traverse(TE.ApplicativePar)(({ input, output }) =>
processFile(input, output)
)
);
const processMultipleFilesSequential = (
files: Array<{ input: string; output: string }>
): TE.TaskEither<Error, void[]> =>
pipe(
files,
A.traverse(TE.ApplicativeSeq)(({ input, output }) =>
processFile(input, output)
)
);
パターン:コールバックベースの API を変換する
import * as TE from 'fp-ts/TaskEither';
// Generic callback-to-TaskEither converter
const fromCallback = <A>(
f: (callback: (error: Error | null, result: A | null) => void) => void
): TE.TaskEither<Error, A> =>
() =>
new Promise((resolve) => {
f((error, result) => {
if (error) {
resolve({ _tag: 'Left', left: error });
} else {
resolve({ _tag: 'Right', right: result as A });
}
});
});
// Usage
const readFileLegacy = (path: string): TE.TaskEither<Error, string> =>
fromCallback((cb) => fs.readFile(path, 'utf-8', cb));
4. クラスベースの DI を Reader に変換する
クラスベース DI の問題点
- クラスとその依存関係が密結合します
- テストにはクラス階層全体のモックが必要です
- 依存性注入コンテナによって実行時の複雑さが増します
- アプリケーション内のデータフローを追跡しにくくなります
パターン:サービスクラスを Reader に変換する
変換前(クラスを使った命令型コード)
// Traditional class-based approach
interface Logger {
log(message: string): void;
error(message: string): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
class UserService {
constructor(
private readonly logger: Logger,
private readonly userRepo: UserRepository,
private readonly emailService: EmailService
) {}
async updateEmail(userId: string, newEmail: string): Promise<void> {
this.logger.log(`Updating email for user ${userId}`);
const user = await this.userRepo.findById(userId);
if (!user) {
this.logger.error(`User ${userId} not found`);
throw new Error(`User ${userId} not found`);
}
const oldEmail = user.email;
user.email = newEmail;
await this.userRepo.save(user);
await this.emailService.send(
oldEmail,
'Email Changed',
`Your email has been changed to ${newEmail}`
);
this.logger.log(`Email updated for user ${userId}`);
}
}
// Manual DI setup
const logger = new ConsoleLogger();
const userRepo = new PostgresUserRepository(dbConnection);
const emailService = new SmtpEmailService(smtpConfig);
const userService = new UserService(logger, userRepo, emailService);
変換後(fp-ts Reader)
import * as R from 'fp-ts/Reader';
import * as RTE from 'fp-ts/ReaderTaskEither';
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
// Define the environment/dependencies as an interface
interface AppEnv {
logger: {
log: (message: string) => void;
error: (message: string) => void;
};
userRepo: {
findById: (id: string) => TE.TaskEither<Error, User | null>;
save: (user: User) => TE.TaskEither<Error, void>;
};
emailService: {
send: (to: string, subject: string, body: string) => TE.TaskEither<Error, void>;
};
}
// Helper to access environment
const ask = RTE.ask<AppEnv, Error>();
// Service functions using ReaderTaskEither
const logInfo = (message: string): RTE.ReaderTaskEither<AppEnv, Error, void> =>
pipe(
ask,
RTE.map((env) => env.logger.log(message))
);
const logError = (message: string): RTE.ReaderTaskEither<AppEnv, Error, void> =>
pipe(
ask,
RTE.map((env) => env.logger.error(message))
);
const findUser = (id: string): RTE.ReaderTaskEither<AppEnv, Error, User | null> =>
pipe(
ask,
RTE.flatMapTaskEither((env) => env.userRepo.findById(id))
);
const saveUser = (user: User): RTE.ReaderTaskEither<AppEnv, Error, void> =>
pipe(
ask,
RTE.flatMapTaskEither((env) => env.userRepo.save(user))
);
const sendEmail = (
to: string,
subject: string,
body: string
): RTE.ReaderTaskEither<AppEnv, Error, void> =>
pipe(
ask,
RTE.flatMapTaskEither((env) => env.emailService.send(to, subject, body))
);
// The updateEmail function using Reader composition
const updateEmail = (
userId: string,
newEmail: string
): RTE.ReaderTaskEither<AppEnv, Error, void> =>
pipe(
logInfo(`Updating email for user ${userId}`),
RTE.flatMap(() => findUser(userId)),
RTE.flatMap((user) => {
if (!user) {
return pipe(
logError(`User ${userId} not found`),
RTE.flatMap(() => RTE.left(new Error(`User ${userId} not found`)))
);
}
const oldEmail = user.email;
const updatedUser = { ...user, email: newEmail };
return pipe(
saveUser(updatedUser),
RTE.flatMap(() =>
sendEmail(
oldEmail,
'Email Changed',
`Your email has been changed to ${newEmail}`
)
),
RTE.flatMap(() => logInfo(`Email updated for user ${userId}`))
);
})
);
// Build the environment
const createAppEnv = (): AppEnv => ({
logger: {
log: (msg) => console.log(`[INFO] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`),
},
userRepo: {
findById: (id) => TE.tryCatch(
() => postgresClient.query('SELECT * FROM users WHERE id = $1', [id]),
(e) => new Error(String(e))
),
save: (user) => TE.tryCatch(
() => postgresClient.query('UPDATE users SET email = $1 WHERE id = $2', [user.email, user.id]),
(e) => new Error(String(e))
),
},
emailService: {
send: (to, subject, body) => TE.tryCatch(
() => smtpClient.send({ to, subject, body }),
(e) => new Error(String(e))
),
},
});
// Run the program
const main = async () => {
const env = createAppEnv();
const result = await updateEmail('user-123', '[email protected]')(env)();
pipe(
result,
E.match(
(error) => console.error('Failed:', error),
() => console.log('Success!')
)
);
};
Reader を使ったテスト
// Easy to test with mock environment
const createTestEnv = (): AppEnv => {
const logs: string[] = [];
const savedUsers: User[] = [];
const sentEmails: Array<{ to: string; subject: string; body: string }> = [];
return {
logger: {
log: (msg) => logs.push(`[INFO] ${msg}`),
error: (msg) => logs.push(`[ERROR] ${msg}`),
},
userRepo: {
findById: (id) =>
TE.right(id === 'existing-user' ? { id, email: '[email protected]', name: 'Test' } : null),
save: (user) => {
savedUsers.push(user);
return TE.right(undefined);
},
},
emailService: {
send: (to, subject, body) => {
sentEmails.push({ to, subject, body });
return TE.right(undefined);
},
},
};
};
// Test
describe('updateEmail', () => {
it('should update email and send notification', async () => {
const env = createTestEnv();
const result = await updateEmail('existing-user', '[email protected]')(env)();
expect(E.isRight(result)).toBe(true);
// Assert on captured side effects
});
});
5. 命令型ループを関数型操作に変換する
パターン:for ループから map/filter/reduce へ
変換前(命令型)
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
function processProducts(products: Product[]): {
totalValue: number;
categoryCounts: Record<string, number>;
expensiveProducts: string[];
} {
let totalValue = 0;
const categoryCounts: Record<string, number> = {};
const expensiveProducts: string[] = [];
for (let i = 0; i < products.length; i++) {
const product = products[i];
// Skip out of stock
if (!product.inStock) {
continue;
}
// Sum total value
totalValue += product.price;
// Count categories
if (categoryCounts[product.category] === undefined) {
categoryCounts[product.category] = 0;
}
categoryCounts[product.category]++;
// Collect expensive products
if (product.price > 100) {
expensiveProducts.push(product.name);
}
}
return { totalValue, categoryCounts, expensiveProducts };
}
変換後(fp-ts の関数型操作)
import * as A from 'fp-ts/Array';
import * as R from 'fp-ts/Record';
import { pipe } from 'fp-ts/function';
import * as N from 'fp-ts/number';
import * as Monoid from 'fp-ts/Monoid';
const processProducts = (products: Product[]) => {
const inStockProducts = pipe(
products,
A.filter((p) => p.inStock)
);
const totalValue = pipe(
inStockProducts,
A.map((p) => p.price),
A.reduce(0, (acc, price) => acc + price)
);
const categoryCounts = pipe(
inStockProducts,
A.reduce({} as Record<string, number>, (acc, product) => ({
...acc,
[product.category]: (acc[product.category] ?? 0) + 1,
}))
);
const expensiveProducts = pipe(
inStockProducts,
A.filter((p) => p.price > 100),
A.map((p) => p.name)
);
return { totalValue, categoryCounts, expensiveProducts };
};
// Or using a single pass with foldMap for efficiency
import { Monoid as M } from 'fp-ts/Monoid';
interface ProductStats {
totalValue: number;
categoryCounts: Record<string, number>;
expensiveProducts: string[];
}
const productStatsMonoid: M<ProductStats> = {
empty: { totalValue: 0, categoryCounts: {}, expensiveProducts: [] },
concat: (a, b) => ({
totalValue: a.totalValue + b.totalValue,
categoryCounts: pipe(
a.categoryCounts,
R.union({ concat: (x, y) => x + y })(b.categoryCounts)
),
expensiveProducts: [...a.expensiveProducts, ...b.expensiveProducts],
}),
};
const processProductsSinglePass = (products: Product[]): ProductStats =>
pipe(
products,
A.filter((p) => p.inStock),
A.foldMap(productStatsMonoid)((product) => ({
totalValue: product.price,
categoryCounts: { [product.category]: 1 },
expensiveProducts: product.price > 100 ? [product.name] : [],
}))
);
パターン:ネストしたループから flatMap へ
変換前(命令型)
interface Order {
id: string;
items: OrderItem[];
}
interface OrderItem {
productId: string;
quantity: number;
}
function getAllProductIds(orders: Order[]): string[] {
const productIds: string[] = [];
for (const order of orders) {
for (const item of order.items) {
if (!productIds.includes(item.productId)) {
productIds.push(item.productId);
}
}
}
return productIds;
}
変換後(fp-ts)
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import * as S from 'fp-ts/Set';
import * as Str from 'fp-ts/string';
const getAllProductIds = (orders: Order[]): string[] =>
pipe(
orders,
A.flatMap((order) => order.items),
A.map((item) => item.productId),
A.uniq(Str.Eq)
);
// Or using Set for better performance with large datasets
const getAllProductIdsSet = (orders: Order[]): Set<string> =>
pipe(
orders,
A.flatMap((order) => order.items),
A.map((item) => item.productId),
(ids) => new Set(ids)
);
パターン:while ループから再帰/unfold へ
変換前(命令型)
function paginate<T>(
fetchPage: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>
): Promise<T[]> {
const allItems: T[] = [];
let cursor: string | null = null;
while (true) {
const { items, nextCursor } = await fetchPage(cursor);
allItems.push(...items);
if (nextCursor === null) {
break;
}
cursor = nextCursor;
}
return allItems;
}
変換後(fp-ts)
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
interface Page<T> {
items: T[];
nextCursor: string | null;
}
const paginate = <T>(
fetchPage: (cursor: string | null) => TE.TaskEither<Error, Page<T>>
): TE.TaskEither<Error, T[]> => {
const go = (
cursor: string | null,
accumulated: T[]
): TE.TaskEither<Error, T[]> =>
pipe(
fetchPage(cursor),
TE.flatMap(({ items, nextCursor }) => {
const newAccumulated = [...accumulated, ...items];
return nextCursor === null
? TE.right(newAccumulated)
: go(nextCursor, newAccumulated);
})
);
return go(null, []);
};
// Using unfold for generating sequences
import * as RA from 'fp-ts/ReadonlyArray';
const range = (start: number, end: number): readonly number[] =>
RA.unfold(start, (n) => (n <= end ? O.some([n, n + 1]) : O.none));
6. Promise チェーンを TaskEither に移行する
パターン:Promise.then チェーンから pipe へ
変換前(命令型)
function fetchUserData(userId: string): Promise<UserProfile> {
return fetch(`/api/users/${userId}`)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then((data) => validateUserData(data))
.then((validData) => enrichUserProfile(validData))
.catch((error) => {
console.error('Failed to fetch user data:', error);
throw error;
});
}
// Chained promises with conditionals
function processOrder(orderId: string): Promise<OrderResult> {
return getOrder(orderId)
.then((order) => {
if (order.status === 'cancelled') {
throw new Error('Order is cancelled');
}
return order;
})
.then((order) => validateInventory(order))
.then((validOrder) => processPayment(validOrder))
.then((paidOrder) => shipOrder(paidOrder))
.catch((error) => {
logError(error);
return { success: false, error: error.message };
});
}
変換後(fp-ts TaskEither)
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
const fetchUserData = (userId: string): TE.TaskEither<Error, UserProfile> =>
pipe(
TE.tryCatch(
() => fetch(`/api/users/${userId}`),
(e) => new Error(`Network error: ${e}`)
),
TE.flatMap((response) =>
response.ok
? TE.tryCatch(
() => response.json(),
(e) => new Error(`Parse error: ${e}`)
)
: TE.left(new Error(`HTTP ${response.status}`))
),
TE.flatMap((data) => TE.fromEither(validateUserData(data))),
TE.flatMap((validData) => enrichUserProfile(validData))
);
// Conditionals are explicit
const processOrder = (orderId: string): TE.TaskEither<Error, OrderResult> =>
pipe(
getOrder(orderId),
TE.filterOrElse(
(order) => order.status !== 'cancelled',
() => new Error('Order is cancelled')
),
TE.flatMap(validateInventory),
TE.flatMap(processPayment),
TE.flatMap(shipOrder),
TE.map((shipped) => ({ success: true, order: shipped })),
TE.orElse((error) =>
pipe(
TE.fromIO(() => logError(error)),
TE.map(() => ({ success: false, error: error.message }))
)
)
);
パターン:Promise.all から traverse へ
変換前(命令型)
async function fetchAllUsers(ids: string[]): Promise<User[]> {
const promises = ids.map((id) => fetchUser(id));
return Promise.all(promises);
}
// With error handling for individual items
async function fetchUsersWithFallback(ids: string[]): Promise<Array<User | null>> {
const promises = ids.map(async (id) => {
try {
return await fetchUser(id);
} catch {
return null;
}
});
return Promise.all(promises);
}
変換後(fp-ts)
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import * as T from 'fp-ts/Task';
import { pipe } from 'fp-ts/function';
// Parallel execution - fails fast on first error
const fetchAllUsers = (ids: string[]): TE.TaskEither<Error, User[]> =>
pipe(
ids,
A.traverse(TE.ApplicativePar)(fetchUser)
);
// Sequential execution
const fetchAllUsersSequential = (ids: string[]): TE.TaskEither<Error, User[]> =>
pipe(
ids,
A.traverse(TE.ApplicativeSeq)(fetchUser)
);
// Collect successes, ignore failures (using Task instead of TaskEither)
const fetchUsersWithFallback = (ids: string[]): T.Task<Array<User | null>> =>
pipe(
ids,
A.traverse(T.ApplicativePar)((id) =>
pipe(
fetchUser(id),
TE.match(
() => null,
(user) => user
)
)
)
);
// Or keep track of which failed
const fetchUsersPartitioned = (
ids: string[]
): T.Task<{ successes: User[]; failures: Array<{ id: string; error: Error }> }> =>
pipe(
ids,
A.traverse(T.ApplicativePar)((id) =>
pipe(
fetchUser(id),
TE.bimap(
(error) => ({ id, error }),
(user) => user
),
(te) => te
)
),
T.map(A.separate),
T.map(({ left: failures, right: successes }) => ({ successes, failures }))
);
パターン:Promise.race から alternative へ
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import { pipe } from 'fp-ts/function';
// Race - first to complete wins
const raceTaskEithers = <E, A>(
tasks: Array<TE.TaskEither<E, A>>
): TE.TaskEither<E, A> =>
() => Promise.race(tasks.map((te) => te()));
// Try alternatives on failure (like Promise.any but typed)
const tryAlternatives = <E, A>(
primary: TE.TaskEither<E, A>,
fallback: TE.TaskEither<E, A>
): TE.TaskEither<E, A> =>
pipe(
primary,
TE.orElse(() => fallback)
);
// Chain of fallbacks
const withFallbacks = <E, A>(
tasks: Array<TE.TaskEither<E, A>>
): TE.TaskEither<E, A> =>
tasks.reduce((acc, task) => pipe(acc, TE.orElse(() => task)));
7. よくある落とし穴
落とし穴 1:Task の実行を忘れる
// WRONG: Task is not executed
const fetchData = (): TE.TaskEither<Error, Data> => /* ... */;
const result = fetchData(); // This is still a Task, not the result!
// CORRECT: Execute the Task
const result = await fetchData()(); // Note the double invocation
落とし穴 2:async/await と fp-ts を誤って組み合わせる
// WRONG: Breaking out of the fp-ts ecosystem
const processData = async (input: string): Promise<Result> => {
const parsed = parseInput(input); // Returns Either
if (E.isLeft(parsed)) {
throw new Error(parsed.left.message); // Don't do this!
}
return await fetchData(parsed.right)();
};
// CORRECT: Stay in the ecosystem
const processData = (input: string): TE.TaskEither<Error, Result> =>
pipe(
parseInput(input),
TE.fromEither,
TE.flatMap(fetchData)
);
落とし穴 3:flatMap が必要なときに map を使う
// WRONG: Results in nested Either
const result: E.Either<Error, E.Either<Error, User>> = pipe(
parseUserId(input), // E.Either<Error, string>
E.map(fetchUser) // Returns E.Either<Error, User>, so we get nested Either
);
// CORRECT: Use flatMap to flatten
const result: E.Either<Error, User> = pipe(
parseUserId(input),
E.flatMap(fetchUser)
);
落とし穴 4:エラー情報を失う
// WRONG: Original error context is lost
const fetchData = (): TE.TaskEither<Error, Data> =>
pipe(
TE.tryCatch(
() => fetch('/api/data'),
() => new Error('Failed') // Lost the original error!
)
);
// CORRECT: Preserve error context
const fetchData = (): TE.TaskEither<Error, Data> =>
pipe(
TE.tryCatch(
() => fetch('/api/data'),
(reason) => new Error(`Network request failed: ${reason}`)
)
);
// BETTER: Use typed errors
type FetchError =
| { _tag: 'NetworkError'; cause: unknown }
| { _tag: 'ParseError'; cause: unknown }
| { _tag: 'ValidationError'; message: string };
const fetchData = (): TE.TaskEither<FetchError, Data> =>
pipe(
TE.tryCatch(
() => fetch('/api/data'),
(cause): FetchError => ({ _tag: 'NetworkError', cause })
),
TE.flatMap((response) =>
TE.tryCatch(
() => response.json(),
(cause): FetchError => ({ _tag: 'ParseError', cause })
)
)
);
落とし穴 5:fromNullable を使いすぎる
// WRONG: Unnecessary wrapping and unwrapping
const getName = (user: User | null): string => {
const optUser = O.fromNullable(user);
const name = pipe(optUser, O.map(u => u.name), O.toNullable);
return name ?? 'Unknown';
};
// CORRECT: Use Option only when you need its composition benefits
const getName = (user: User | null): string => user?.name ?? 'Unknown';
// BETTER: Use Option when chaining multiple operations
const getManagerName = (user: User | null): O.Option<string> =>
pipe(
O.fromNullable(user),
O.flatMap(u => O.fromNullable(u.manager)),
O.map(m => m.name)
);
落とし穴 6:left のケースを処理しない
// WRONG: Ignoring potential errors
const processUser = (input: string): User => {
const result = parseUser(input); // E.Either<Error, User>
return (result as E.Right<User>).right; // Unsafe cast!
};
// CORRECT: Always handle both cases
const processUser = (input: string): User =>
pipe(
parseUser(input),
E.getOrElse((error) => {
console.error('Parse failed:', error);
return defaultUser;
})
);
8. 段階的な導入戦略
戦略 1:境界から始める
まず、システムの境界にある関数を変換します。
- API レスポンスハンドラー
- データベースクエリの結果
- ファイルシステム操作
- ユーザー入力の検証
// Wrap external API calls first
const fetchUserApi = (id: string): TE.TaskEither<ApiError, UserDto> =>
pipe(
TE.tryCatch(
() => externalApiClient.getUser(id),
(e) => ({ type: 'api_error' as const, cause: e })
)
);
// Internal code can stay imperative initially
async function handleUserRequest(userId: string) {
const result = await fetchUserApi(userId)();
if (E.isRight(result)) {
// Process user with existing code
return processUser(result.right);
} else {
throw new Error(`API error: ${result.left.type}`);
}
}
戦略 2:ブリッジ関数を作成する
fp-ts と命令型コードの間で変換するヘルパーを作成します。
// Bridge from Either to thrown errors
const unsafeUnwrap = <E, A>(either: E.Either<E, A>): A =>
pipe(
either,
E.getOrElseW((e) => {
throw e instanceof Error ? e : new Error(String(e));
})
);
// Bridge from thrown errors to Either
const catchSync = <A>(f: () => A): E.Either<Error, A> =>
E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e))));
// Bridge from Promise to TaskEither
const fromPromise = <A>(p: Promise<A>): TE.TaskEither<Error, A> =>
TE.tryCatch(() => p, (e) => (e instanceof Error ? e : new Error(String(e))));
// Bridge from TaskEither to Promise (throws on Left)
const toPromise = <E, A>(te: TE.TaskEither<E, A>): Promise<A> =>
te().then(E.getOrElseW((e) => { throw e; }));
戦略 3:モジュールごとに移行する
- 境界が明確なモジュールを選びます
- 内部関数にfp-ts の型を追加します
- 最初は外部 API を変更せずに保ちます
- 次に進む前に十分にテストします
- 内部の実装が安定したら外部 API を更新します
// Phase 1: Internal functions use fp-ts
// File: user-service.internal.ts
export const validateUser = (data: unknown): E.Either<ValidationError, User> => /* ... */;
export const enrichUser = (user: User): TE.TaskEither<Error, EnrichedUser> => /* ... */;
// File: user-service.ts (public API unchanged)
export async function getUser(id: string): Promise<User> {
const result = await pipe(
fetchUser(id),
TE.flatMap(validateUser >>> TE.fromEither),
TE.flatMap(enrichUser)
)();
if (E.isLeft(result)) {
throw result.left;
}
return result.right;
}
// Phase 2: Update public API
// File: user-service.ts
export const getUser = (id: string): TE.TaskEither<UserError, User> =>
pipe(
fetchUser(id),
TE.flatMap(validateUser >>> TE.fromEither),
TE.flatMap(enrichUser)
);
戦略 4:型駆動開発
TypeScript の型システムを使って移行の指針にします。
// Step 1: Change type signature first
type OldGetUser = (id: string) => Promise<User | null>;
type NewGetUser = (id: string) => TE.TaskEither<UserError, User>;
// Step 2: Compiler will show all call sites that need updating
const getUser: NewGetUser = (id) => /* implement */;
// Step 3: Update call sites one by one
// The compiler ensures you handle all cases
戦略 5:ドキュメントとしてのテスト
期待される動作を示すテストを記述します。
describe('UserService', () => {
describe('getUser (fp-ts)', () => {
it('returns Right with user on success', async () => {
const result = await getUser('valid-id')();
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right.id).toBe('valid-id');
}
});
it('returns Left with NotFound error for unknown id', async () => {
const result = await getUser('unknown')();
expect(E.isLeft(result)).toBe(true);
if (E.isLeft(result)) {
expect(result.left._tag).toBe('NotFound');
}
});
});
});
9. リファクタリングすべきでない場合
単純な同期コード
fp-ts のメリットがない単純なコードはリファクタリングしないでください。
// This is fine as-is
function formatName(first: string, last: string): string {
return `${first} ${last}`;
}
// Don't do this - it adds complexity without benefit
const formatName = (first: string, last: string): string =>
pipe(
first,
(f) => `${f} ${last}`
);
パフォーマンスが重要なループ
fp-ts の操作では中間配列が作成されます。ホットパスでは命令型コードを維持してください。
// Keep this for performance-critical code processing millions of items
function sumLargeArray(numbers: number[]): number {
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
// This creates intermediate arrays
const sumWithFpts = (numbers: number[]): number =>
pipe(numbers, A.reduce(0, (acc, n) => acc + n));
サードパーティライブラリのインターフェイス
特定のパターンを想定しているライブラリを扱う場合:
// Express middleware must match Express's interface
app.get('/users/:id', async (req, res) => {
// Keep imperative here, convert at boundaries
const result = await getUser(req.params.id)();
if (E.isLeft(result)) {
res.status(404).json({ error: result.left.message });
} else {
res.json(result.right);
}
});
関数型プログラミングに詳しくないチームメンバーが触るコード
チームが fp-ts に慣れていない場合、無理に導入すると生産性が低下します。
// If team doesn't know fp-ts, this is harder to maintain
const processOrder = (order: Order): TE.TaskEither<Error, Result> =>
pipe(
validateOrder(order),
TE.fromEither,
TE.flatMap(enrichOrder),
TE.flatMap(submitOrder)
);
// Familiar to all TypeScript developers
async function processOrder(order: Order): Promise<Result> {
const validated = validateOrder(order);
if (!validated.success) {
throw new Error(validated.error);
}
const enriched = await enrichOrder(validated.data);
return await submitOrder(enriched);
}
簡単な null チェック
単発の簡単な null チェックに Option を使わないでください。
// This is fine
const name = user?.name ?? 'Anonymous';
// Overkill for simple cases
const name = pipe(
O.fromNullable(user),
O.map((u) => u.name),
O.getOrElse(() => 'Anonymous')
);
エラー型が重要でない場合
いずれにしても例外を投げる、またはログに記録するだけで、エラーの合成が必要ない場合:
// If this is your error handling anyway...
try {
await doSomething();
} catch (e) {
logger.error(e);
throw e;
}
// ...then Either doesn't add much value
const result = await doSomethingTE()();
if (E.isLeft(result)) {
logger.error(result.left);
throw result.left;
}
テストコード
テストコードでは、必ずしも関数型にする必要はなく、読みやすさを重視してください。
// Clear test code
describe('UserService', () => {
it('creates a user', async () => {
const user = await createUser({ name: 'Alice' });
expect(user.name).toBe('Alice');
});
});
// Unnecessarily complex
describe('UserService', () => {
it('creates a user', async () => {
await pipe(
createUser({ name: 'Alice' }),
TE.map((user) => expect(user.name).toBe('Alice')),
TE.getOrElse(() => T.of(fail('Should not fail')))
)();
});
});
クイックリファレンス:命令型から fp-ts への対応表
| 命令型のパターン | fp-ts での対応 |
|---|---|
try { } catch { } | E.tryCatch(), TE.tryCatch() |
throw new Error() | E.left(), TE.left() |
return value | E.right(), TE.right() |
if (x === null) | O.fromNullable(), O.isNone() |
x ?? defaultValue | O.getOrElse() |
x?.property | O.map(), O.flatMap() |
array.map() | A.map() |
array.filter() | A.filter() |
array.reduce() | A.reduce(), A.foldMap() |
array.find() | A.findFirst() |
array.flatMap() | A.flatMap() |
Promise.then() | TE.map(), TE.flatMap() |
Promise.catch() | TE.orElse(), TE.mapLeft() |
Promise.all() | A.traverse(TE.ApplicativePar) |
async/await | TE.flatMap() チェーン |
new Class(deps) | R.asks(), RTE.ask() |
for...of | A.map(), A.reduce() |
while | 再帰、unfold() |
概要
fp-ts への移行は、目的地ではなく道のりです。主な原則は次のとおりです。
- 小さく始める:コードベース全体ではなく、個々の関数を変換します
- 実用性を重視する:すべてを関数型にする必要はありません
- 型を活用する:コンパイラーにリファクタリングの指針を示してもらいます
- 十分にテストする:変換のたびに検証します
- パターンを文書化する:コードベースに合わせたチーム固有のガイドを作成します
- メリットを見直す:複雑さが増すことで価値が得られることを確認します
目標は、関数型プログラミング自体を目的とするのではなく、保守しやすく型安全なコードにすることです。