fp-refactor
Comprehensive guide for refactoring imperative TypeScript code to fp-ts functional patterns
- risk
- unknown
- source
- community
- version
- 1.0.0
- author
- fp-ts-skills
Refactoring Imperative Code to fp-ts
This skill provides comprehensive patterns and strategies for migrating existing imperative TypeScript code to fp-ts functional programming patterns.
Table of Contents
- Converting try-catch to Either/TaskEither
- Converting null checks to Option
- Converting callbacks to Task
- Converting class-based DI to Reader
- Converting imperative loops to functional operations
- Migrating Promise chains to TaskEither
- Common Pitfalls
- Gradual Adoption Strategies
- When NOT to Refactor
1. Converting try-catch to Either/TaskEither
The Problem with try-catch
Traditional try-catch blocks have several issues:
- Error handling is implicit and easy to forget
- The type system doesn't track which functions can throw
- Control flow is non-linear and harder to reason about
- Composing multiple fallible operations is verbose
Pattern: Synchronous try-catch to Either
Before (Imperative)
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; } }
After (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) ) );
Step-by-Step Refactoring Guide
- Identify the error type: Determine what errors can occur and create appropriate error types
- Change return type: From
TtoEither<E, T>whereEis your error type - Replace throw statements: Convert
throw new Error(...)toE.left(new Error(...)) - Replace return statements: Convert
return valuetoE.right(value) - Remove try-catch blocks: They're no longer needed
- Update callers: Use
pipewithE.flatMapto chain operations
Pattern: Async try-catch to TaskEither
Before (Imperative)
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; } }
After (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) ) ); };
Helper: tryCatch Utility
Create a reusable wrapper for functions that might throw:
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. Converting null checks to Option
The Problem with null/undefined
- TypeScript's strict null checks help, but null still spreads through code
- Chained property access requires verbose null guards
- The distinction between "missing" and "present but null" is unclear
- Easy to forget null checks leading to runtime errors
Pattern: Simple null checks to Option
Before (Imperative)
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'); }
After (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) ) );
Pattern: Array find operations
Before (Imperative)
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; }
After (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) );
Step-by-Step Refactoring Guide
- Identify nullable values: Find all
T | null,T | undefined, or optional properties - Wrap with fromNullable: Convert nullable values to Option at system boundaries
- Change return types: From
T | nulltoOption<T> - Replace null checks: Use
O.map,O.flatMap,O.filterinstead of if statements - Handle at boundaries: Use
O.getOrElse,O.match, orO.toNullablewhen interfacing with non-fp code
Converting Between Option and 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. Converting callbacks to Task
The Problem with Callbacks
- Callback hell makes code hard to read
- Error handling is inconsistent
- Difficult to compose and sequence
- No standard way to handle async operations
Pattern: Node-style callbacks to Task
Before (Imperative)
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); } }); }); }
After (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) ) );
Pattern: Converting callback-based APIs
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. Converting class-based DI to Reader
The Problem with Class-based DI
- Tight coupling between classes and their dependencies
- Testing requires mocking entire class hierarchies
- Dependency injection containers add runtime complexity
- Hard to trace data flow through the application
Pattern: Service classes to Reader
Before (Imperative with Classes)
// 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);
After (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', 'new@email.com')(env)(); pipe( result, E.match( (error) => console.error('Failed:', error), () => console.log('Success!') ) ); };
Testing with 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: 'old@email.com', 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', 'new@email.com')(env)(); expect(E.isRight(result)).toBe(true); // Assert on captured side effects }); });
5. Converting imperative loops to functional operations
Pattern: for loops to map/filter/reduce
Before (Imperative)
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 }; }
After (fp-ts functional operations)
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] : [], })) );
Pattern: Nested loops to flatMap
Before (Imperative)
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; }
After (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) );
Pattern: while loops to recursion/unfold
Before (Imperative)
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; }
After (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. Migrating Promise chains to TaskEither
Pattern: Promise.then chains to pipe
Before (Imperative)
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 }; }); }
After (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 })) ) ) );
Pattern: Promise.all to traverse
Before (Imperative)
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); }
After (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 })) );
Pattern: Promise.race to 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. Common Pitfalls
Pitfall 1: Forgetting to run Tasks
// 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
Pitfall 2: Mixing async/await with fp-ts incorrectly
// 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) );
Pitfall 3: Using map when flatMap is needed
// 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) );
Pitfall 4: Losing error information
// 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 }) ) ) );
Pitfall 5: Overusing 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) );
Pitfall 6: Not handling the left case
// 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. Gradual Adoption Strategies
Strategy 1: Start at the Boundaries
Begin by converting functions at the edges of your system:
- API response handlers
- Database query results
- File system operations
- User input validation
// 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}`); } }
Strategy 2: Create Bridge Functions
Build helpers to convert between fp-ts and imperative code:
// 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; }));
Strategy 3: Module-by-Module Migration
- Pick a module with clear boundaries
- Add fp-ts types to internal functions
- Keep external API unchanged initially
- Test thoroughly before moving on
- Update external API once internals are stable
// 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) );
Strategy 4: Type-Driven Development
Use TypeScript's type system to guide the migration:
// 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
Strategy 5: Testing as Documentation
Write tests that demonstrate the expected behavior:
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. When NOT to Refactor
Simple Synchronous Code
Don't refactor straightforward code that doesn't benefit from 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}` );
Performance-Critical Loops
fp-ts operations create intermediate arrays. For hot paths, keep imperative code:
// 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));
Third-Party Library Interfaces
When working with libraries that expect specific patterns:
// 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); } });
Code Touched by Non-FP Team Members
If your team isn't familiar with fp-ts, forced adoption will hurt productivity:
// 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); }
Trivial Null Checks
Don't use Option for simple, one-off null checks:
// 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') );
When the Error Type Doesn't Matter
If you're going to throw/log anyway and don't need error composition:
// 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; }
Test Code
Test code should be readable, not necessarily functional:
// 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'))) )(); }); });
Quick Reference: Imperative to fp-ts Mapping
| Imperative Pattern | fp-ts Equivalent |
|---|---|
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() chain |
new Class(deps) | R.asks(), RTE.ask() |
for...of | A.map(), A.reduce() |
while | Recursion, unfold() |
Summary
Migrating to fp-ts is a journey, not a destination. Key principles:
- Start small: Convert individual functions, not entire codebases
- Be pragmatic: Not everything needs to be functional
- Type-driven: Let the compiler guide your refactoring
- Test thoroughly: Each conversion should be verified
- Document patterns: Create team-specific guides for your codebase
- Review benefits: Ensure the added complexity provides value
The goal is more maintainable, type-safe code—not functional programming for its own sake.