--- name: fp-refactor description: Comprehensive guide for refactoring imperative TypeScript code to fp-ts functional patterns version: 1.0.0 author: fp-ts-skills tags: - fp-ts - refactoring - functional-programming - typescript - migration - either - option - task - reader --- # 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 1. [Converting try-catch to Either/TaskEither](#1-converting-try-catch-to-eithertaskeither) 2. [Converting null checks to Option](#2-converting-null-checks-to-option) 3. [Converting callbacks to Task](#3-converting-callbacks-to-task) 4. [Converting class-based DI to Reader](#4-converting-class-based-di-to-reader) 5. [Converting imperative loops to functional operations](#5-converting-imperative-loops-to-functional-operations) 6. [Migrating Promise chains to TaskEither](#6-migrating-promise-chains-to-taskeither) 7. [Common Pitfalls](#7-common-pitfalls) 8. [Gradual Adoption Strategies](#8-gradual-adoption-strategies) 9. [When NOT to Refactor](#9-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) ```typescript 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; 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) ```typescript 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 const parseJSON = (input: string): E.Either => 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 => { if (!data || typeof data !== 'object') { return E.left(new Error('Data must be an object')); } const obj = data as Record; 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 => 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 1. **Identify the error type**: Determine what errors can occur and create appropriate error types 2. **Change return type**: From `T` to `Either` where `E` is your error type 3. **Replace throw statements**: Convert `throw new Error(...)` to `E.left(new Error(...))` 4. **Replace return statements**: Convert `return value` to `E.right(value)` 5. **Remove try-catch blocks**: They're no longer needed 6. **Update callers**: Use `pipe` with `E.flatMap` to chain operations ### Pattern: Async try-catch to TaskEither #### Before (Imperative) ```typescript async function fetchUser(id: string): Promise { 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 { 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) ```typescript 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 => 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 => 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 => 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: ```typescript import * as E from 'fp-ts/Either'; import * as TE from 'fp-ts/TaskEither'; // For sync functions const tryCatchSync = (f: () => A): E.Either => E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e)))); // For async functions const tryCatchAsync = (f: () => Promise): TE.TaskEither => 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) ```typescript 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) ```typescript import * as O from 'fp-ts/Option'; import { pipe } from 'fp-ts/function'; const getDatabaseUrl = (config: Config): O.Option => 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) ```typescript 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) ```typescript 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 => A.findFirst((u) => u.id === id)(users); const getUserEmail = (users: User[], id: string): O.Option => pipe( findUserById(users, id), O.map((user) => user.email) ); const getManagerEmail = ( users: User[], employee: { managerId?: string } ): O.Option => pipe( O.fromNullable(employee.managerId), O.flatMap((managerId) => findUserById(users, managerId)), O.map((manager) => manager.email) ); ``` ### Step-by-Step Refactoring Guide 1. **Identify nullable values**: Find all `T | null`, `T | undefined`, or optional properties 2. **Wrap with fromNullable**: Convert nullable values to Option at system boundaries 3. **Change return types**: From `T | null` to `Option` 4. **Replace null checks**: Use `O.map`, `O.flatMap`, `O.filter` instead of if statements 5. **Handle at boundaries**: Use `O.getOrElse`, `O.match`, or `O.toNullable` when interfacing with non-fp code ### Converting Between Option and Either ```typescript 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 = (onNone: () => E) => ( option: O.Option ): E.Either => pipe( option, E.fromOption(onNone) ); // Example const findUser = (id: string): O.Option => /* ... */; const getUser = (id: string): E.Either => 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) ```typescript 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) ```typescript 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 => TE.tryCatch( () => fs.readFile(path, 'utf-8'), (e) => (e instanceof Error ? e : new Error(String(e))) ); const writeFile = (path: string, data: string): TE.TaskEither => 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 => 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 => pipe( files, A.traverse(TE.ApplicativePar)(({ input, output }) => processFile(input, output) ) ); const processMultipleFilesSequential = ( files: Array<{ input: string; output: string }> ): TE.TaskEither => pipe( files, A.traverse(TE.ApplicativeSeq)(({ input, output }) => processFile(input, output) ) ); ``` ### Pattern: Converting callback-based APIs ```typescript import * as TE from 'fp-ts/TaskEither'; // Generic callback-to-TaskEither converter const fromCallback = ( f: (callback: (error: Error | null, result: A | null) => void) => void ): TE.TaskEither => () => 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 => 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) ```typescript // Traditional class-based approach interface Logger { log(message: string): void; error(message: string): void; } interface UserRepository { findById(id: string): Promise; save(user: User): Promise; } interface EmailService { send(to: string, subject: string, body: string): Promise; } class UserService { constructor( private readonly logger: Logger, private readonly userRepo: UserRepository, private readonly emailService: EmailService ) {} async updateEmail(userId: string, newEmail: string): Promise { 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) ```typescript 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; save: (user: User) => TE.TaskEither; }; emailService: { send: (to: string, subject: string, body: string) => TE.TaskEither; }; } // Helper to access environment const ask = RTE.ask(); // Service functions using ReaderTaskEither const logInfo = (message: string): RTE.ReaderTaskEither => pipe( ask, RTE.map((env) => env.logger.log(message)) ); const logError = (message: string): RTE.ReaderTaskEither => pipe( ask, RTE.map((env) => env.logger.error(message)) ); const findUser = (id: string): RTE.ReaderTaskEither => pipe( ask, RTE.flatMapTaskEither((env) => env.userRepo.findById(id)) ); const saveUser = (user: User): RTE.ReaderTaskEither => pipe( ask, RTE.flatMapTaskEither((env) => env.userRepo.save(user)) ); const sendEmail = ( to: string, subject: string, body: string ): RTE.ReaderTaskEither => 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 => 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 ```typescript // 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) ```typescript interface Product { id: string; name: string; price: number; category: string; inStock: boolean; } function processProducts(products: Product[]): { totalValue: number; categoryCounts: Record; expensiveProducts: string[]; } { let totalValue = 0; const categoryCounts: Record = {}; 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) ```typescript 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, (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; expensiveProducts: string[]; } const productStatsMonoid: M = { 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) ```typescript 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) ```typescript 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 => pipe( orders, A.flatMap((order) => order.items), A.map((item) => item.productId), (ids) => new Set(ids) ); ``` ### Pattern: while loops to recursion/unfold #### Before (Imperative) ```typescript function paginate( fetchPage: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }> ): Promise { 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) ```typescript import * as TE from 'fp-ts/TaskEither'; import * as A from 'fp-ts/Array'; import { pipe } from 'fp-ts/function'; interface Page { items: T[]; nextCursor: string | null; } const paginate = ( fetchPage: (cursor: string | null) => TE.TaskEither> ): TE.TaskEither => { const go = ( cursor: string | null, accumulated: T[] ): TE.TaskEither => 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) ```typescript function fetchUserData(userId: string): Promise { 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 { 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) ```typescript 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 => 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 => 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) ```typescript async function fetchAllUsers(ids: string[]): Promise { const promises = ids.map((id) => fetchUser(id)); return Promise.all(promises); } // With error handling for individual items async function fetchUsersWithFallback(ids: string[]): Promise> { const promises = ids.map(async (id) => { try { return await fetchUser(id); } catch { return null; } }); return Promise.all(promises); } ``` #### After (fp-ts) ```typescript 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 => pipe( ids, A.traverse(TE.ApplicativePar)(fetchUser) ); // Sequential execution const fetchAllUsersSequential = (ids: string[]): TE.TaskEither => pipe( ids, A.traverse(TE.ApplicativeSeq)(fetchUser) ); // Collect successes, ignore failures (using Task instead of TaskEither) const fetchUsersWithFallback = (ids: string[]): T.Task> => 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 ```typescript 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 = ( tasks: Array> ): TE.TaskEither => () => Promise.race(tasks.map((te) => te())); // Try alternatives on failure (like Promise.any but typed) const tryAlternatives = ( primary: TE.TaskEither, fallback: TE.TaskEither ): TE.TaskEither => pipe( primary, TE.orElse(() => fallback) ); // Chain of fallbacks const withFallbacks = ( tasks: Array> ): TE.TaskEither => tasks.reduce((acc, task) => pipe(acc, TE.orElse(() => task))); ``` --- ## 7. Common Pitfalls ### Pitfall 1: Forgetting to run Tasks ```typescript // WRONG: Task is not executed const fetchData = (): TE.TaskEither => /* ... */; 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 ```typescript // WRONG: Breaking out of the fp-ts ecosystem const processData = async (input: string): Promise => { 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 => pipe( parseInput(input), TE.fromEither, TE.flatMap(fetchData) ); ``` ### Pitfall 3: Using map when flatMap is needed ```typescript // WRONG: Results in nested Either const result: E.Either> = pipe( parseUserId(input), // E.Either E.map(fetchUser) // Returns E.Either, so we get nested Either ); // CORRECT: Use flatMap to flatten const result: E.Either = pipe( parseUserId(input), E.flatMap(fetchUser) ); ``` ### Pitfall 4: Losing error information ```typescript // WRONG: Original error context is lost const fetchData = (): TE.TaskEither => pipe( TE.tryCatch( () => fetch('/api/data'), () => new Error('Failed') // Lost the original error! ) ); // CORRECT: Preserve error context const fetchData = (): TE.TaskEither => 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 => 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 ```typescript // 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 => pipe( O.fromNullable(user), O.flatMap(u => O.fromNullable(u.manager)), O.map(m => m.name) ); ``` ### Pitfall 6: Not handling the left case ```typescript // WRONG: Ignoring potential errors const processUser = (input: string): User => { const result = parseUser(input); // E.Either return (result as E.Right).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 ```typescript // Wrap external API calls first const fetchUserApi = (id: string): TE.TaskEither => 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: ```typescript // Bridge from Either to thrown errors const unsafeUnwrap = (either: E.Either): A => pipe( either, E.getOrElseW((e) => { throw e instanceof Error ? e : new Error(String(e)); }) ); // Bridge from thrown errors to Either const catchSync = (f: () => A): E.Either => E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e)))); // Bridge from Promise to TaskEither const fromPromise = (p: Promise): TE.TaskEither => TE.tryCatch(() => p, (e) => (e instanceof Error ? e : new Error(String(e)))); // Bridge from TaskEither to Promise (throws on Left) const toPromise = (te: TE.TaskEither): Promise => te().then(E.getOrElseW((e) => { throw e; })); ``` ### Strategy 3: Module-by-Module Migration 1. **Pick a module** with clear boundaries 2. **Add fp-ts types** to internal functions 3. **Keep external API unchanged** initially 4. **Test thoroughly** before moving on 5. **Update external API** once internals are stable ```typescript // Phase 1: Internal functions use fp-ts // File: user-service.internal.ts export const validateUser = (data: unknown): E.Either => /* ... */; export const enrichUser = (user: User): TE.TaskEither => /* ... */; // File: user-service.ts (public API unchanged) export async function getUser(id: string): Promise { 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 => 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: ```typescript // Step 1: Change type signature first type OldGetUser = (id: string) => Promise; type NewGetUser = (id: string) => TE.TaskEither; // 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: ```typescript 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: ```typescript // 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: ```typescript // 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: ```typescript // 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: ```typescript // If team doesn't know fp-ts, this is harder to maintain const processOrder = (order: Order): TE.TaskEither => pipe( validateOrder(order), TE.fromEither, TE.flatMap(enrichOrder), TE.flatMap(submitOrder) ); // Familiar to all TypeScript developers async function processOrder(order: Order): Promise { 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: ```typescript // 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: ```typescript // 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: ```typescript // 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: 1. **Start small**: Convert individual functions, not entire codebases 2. **Be pragmatic**: Not everything needs to be functional 3. **Type-driven**: Let the compiler guide your refactoring 4. **Test thoroughly**: Each conversion should be verified 5. **Document patterns**: Create team-specific guides for your codebase 6. **Review benefits**: Ensure the added complexity provides value The goal is more maintainable, type-safe code—not functional programming for its own sake.