| name | fp-refactor |
| description | Comprehensive guide for refactoring imperative TypeScript code to fp-ts functional patterns |
| risk | unknown |
| source | community |
| 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
- 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., : obj. };
} (error) {
error;
}
}
(): | {
{
data = (input);
user = (data);
user;
} (error) {
.(, error);
;
}
}
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;
}
const parseJSON = (input: string): E.Either<Error, unknown> =>
pipe(
J.parse(input),
E.mapLeft((e) => new Error(`Invalid JSON: ${e}`))
);
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, >;
( obj. !== ) {
E.( ());
}
( obj. !== ) {
E.( ());
}
E.({ : obj., : obj. });
};
processUserInput = (: ): E.<, > =>
(
(input),
E.(validateUser)
);
(
(),
E.(
.(, error.),
.(, user)
)
);
Step-by-Step Refactoring Guide
- Identify the error type: Determine what errors can occur and create appropriate error types
- Change return type: From
T to Either<E, T> where E is your error type
- Replace throw statements: Convert
throw new Error(...) to E.left(new Error(...))
- Replace return statements: Convert
return value to E.right(value)
- Remove try-catch blocks: They're no longer needed
- Update callers: Use
pipe with E.flatMap to 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}`);
}
response.();
} (error) {
();
}
}
(): <{ : ; : [] } | > {
{
user = (id);
posts = (id);
{ user, posts };
} (error) {
.(error);
;
}
}
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 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.(),
()
)
),
.( .((data)))
);
fetchUserPosts = (: ): .<, []> =>
(
.(
(),
()
),
.(
response.
? .(response)
: .( ())
),
.(
.(
response.(),
()
)
)
);
getUserWithPosts = (
:
): .<, { : ; : [] }> =>
(
.,
.(, (id)),
.(, (id))
);
= () => {
result = ()();
(
result,
E.(
.(, error.),
.(, 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';
const tryCatchSync = <A>(f: () => A): E.Either<Error, A> =>
E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e))));
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}`;
}
url = (config);
(url !== ) {
(url);
} {
.();
}
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 }) => )
)
),
O.( )
);
;
})
)
)
);
(
(config),
O.(
.(),
(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;
}
function getManagerEmail(users: User[], employee: { managerId?: string }): string | null {
if (!employee.managerId) {
return null;
}
const manager = findUserById(users, employee.);
(!manager) {
;
}
manager.;
}
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.)
);
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 | null to Option<T>
- Replace null checks: Use
O.map, O.flatMap, O.filter instead of if statements
- Handle at boundaries: Use
O.getOrElse, O.match, or O.toNullable when 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';
const optionToEither = <E, A>(onNone: () => E) => (
option: O.Option<A>
): E.Either<E, A> =>
pipe(
option,
E.fromOption(onNone)
);
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);
} {
();
}
});
});
}
(): {
completed = ;
hasError = ;
files.( {
(hasError) ;
(input, output, {
(hasError) ;
(err) {
hasError = ;
(err);
;
}
completed++;
(completed === files.) {
();
}
});
});
}
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';
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 : ((e)))
);
processFile = (
: ,
:
): .<, > =>
(
(inputPath),
.( data.()),
.( (outputPath, processed))
);
processMultipleFilesParallel = (
: <{ : ; : }>
): .<, []> =>
(
files,
A.(.)(
(input, output)
)
);
processMultipleFilesSequential = (
: <{ : ; : }>
): .<, []> =>
(
files,
A.(.)(
(input, output)
)
);
Pattern: Converting callback-based APIs
import * as TE from 'fp-ts/TaskEither';
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 });
}
});
});
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)
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(: , : ): <> {
..();
user = ..(userId);
(!user) {
..();
();
}
oldEmail = user.;
user. = newEmail;
..(user);
..(
oldEmail,
,
);
..();
}
}
logger = ();
userRepo = (dbConnection);
emailService = (smtpConfig);
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';
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: .<, >;
};
}
ask = .<, >();
logInfo = (: ): .<, , > =>
(
ask,
.( env..(message))
);
logError = (: ): .<, , > =>
(
ask,
.( env..(message))
);
findUser = (: ): .<, , | > =>
(
ask,
.( env..(id))
);
saveUser = (: ): .<, , > =>
(
ask,
.( env..(user))
);
sendEmail = (
: ,
: ,
:
): .<, , > =>
(
ask,
.( env..(to, subject, body))
);
updateEmail = (
: ,
:
): .<, , > =>
(
(),
.( (userId)),
.( {
(!user) {
(
(),
.( .( ()))
);
}
oldEmail = user.;
updatedUser = { ...user, : newEmail };
(
(updatedUser),
.(
(
oldEmail,
,
)
),
.( ())
);
})
);
createAppEnv = (): ({
: {
: .(),
: .(),
},
: {
: .(
postgresClient.(, [id]),
((e))
),
: .(
postgresClient.(, [user., user.]),
((e))
),
},
: {
: .(
smtpClient.({ to, subject, body }),
((e))
),
},
});
= () => {
env = ();
result = (, )(env)();
(
result,
E.(
.(, error),
.()
)
);
};
Testing with Reader
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);
},
},
: {
: {
sentEmails.({ to, subject, body });
.();
},
},
};
};
(, {
(, () => {
env = ();
result = (, )(env)();
(E.(result)).();
});
});
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];
if (!product.inStock) {
continue;
}
totalValue += product.price;
if (categoryCounts[product.category] === undefined) {
categoryCounts[product.category] = ;
}
categoryCounts[product.]++;
(product. > ) {
expensiveProducts.(product.);
}
}
{ 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.] ?? ) + ,
}))
);
expensiveProducts = (
inStockProducts,
A.( p. > ),
A.( p.)
);
{ totalValue, categoryCounts, expensiveProducts };
};
{ M } ;
{
: ;
: <, >;
: [];
}
: M<> = {
: { : , : {}, : [] },
: ({
: a. + b.,
: (
a.,
R.({ : x + y })(b.)
),
: [...a., ...b.],
}),
};
processProductsSinglePass = (: []):
(
products,
A.( p.),
A.(productStatsMonoid)( ({
: product.,
: { [product.]: },
: product. > ? [product.] : [],
}))
);
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)
);
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)
: (nextCursor, newAccumulated);
})
);
(, []);
};
* ;
range = (: , : ): [] =>
.(start, (n <= end ? O.([n, n + ]) : O.));
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;
});
}
function processOrder(orderId: string): Promise<OrderResult> {
return getOrder(orderId)
.then((order) => {
(order. === ) {
();
}
order;
})
.( (order))
.( (validOrder))
.( (paidOrder))
.( {
(error);
{ : , : error. };
});
}
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 `))
),
.( .((data))),
.( (validData))
);
processOrder = (: ): .<, > =>
(
(orderId),
.(
order. !== ,
()
),
.(validateInventory),
.(processPayment),
.(shipOrder),
.( ({ : , : shipped })),
.(
(
.( (error)),
.( ({ : , : error. }))
)
)
);
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);
}
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';
const fetchAllUsers = (ids: string[]): TE.TaskEither<Error, User[]> =>
pipe(
ids,
A.traverse(TE.ApplicativePar)(fetchUser)
);
const fetchAllUsersSequential = (ids: string[]): TE.TaskEither<Error, User[]> =>
pipe(
ids,
A.traverse(TE.ApplicativeSeq)(fetchUser)
);
const fetchUsersWithFallback = (ids: string[]): T.Task<Array<User | null>> =>
pipe(
ids,
A.(T.)(
(
(id),
.(
,
user
)
)
)
);
fetchUsersPartitioned = (
: []
): T.<{ : []; : <{ : ; : }> }> =>
(
ids,
A.(T.)(
(
(id),
.(
({ id, error }),
user
),
te
)
),
T.(A.),
T.( ({ 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';
const raceTaskEithers = <E, A>(
tasks: Array<TE.TaskEither<E, A>>
): TE.TaskEither<E, A> =>
() => Promise.race(tasks.map((te) => te()));
const tryAlternatives = <E, A>(
primary: TE.TaskEither<E, A>,
fallback: TE.TaskEither<E, A>
): TE.TaskEither<E, A> =>
pipe(
primary,
TE.orElse(() => fallback)
);
const withFallbacks = <E, A>(
tasks: Array<TE.TaskEither<E, A>>
): TE.TaskEither<E, A> =>
tasks.( (acc, .( task)));
7. Common Pitfalls
Pitfall 1: Forgetting to run Tasks
const fetchData = (): TE.TaskEither<Error, Data> => ;
const result = fetchData();
const result = await fetchData()();
Pitfall 2: Mixing async/await with fp-ts incorrectly
const processData = async (input: string): Promise<Result> => {
const parsed = parseInput(input);
if (E.isLeft(parsed)) {
throw new Error(parsed.left.message);
}
return await fetchData(parsed.right)();
};
const processData = (input: string): TE.TaskEither<Error, Result> =>
pipe(
parseInput(input),
TE.fromEither,
TE.flatMap(fetchData)
);
Pitfall 3: Using map when flatMap is needed
const result: E.Either<Error, E.Either<Error, User>> = pipe(
parseUserId(input),
E.map(fetchUser)
);
const result: E.Either<Error, User> = pipe(
parseUserId(input),
E.flatMap(fetchUser)
);
Pitfall 4: Losing error information
const fetchData = (): TE.TaskEither<Error, Data> =>
pipe(
TE.tryCatch(
() => fetch('/api/data'),
() => new Error('Failed')
)
);
const fetchData = (): TE.TaskEither<Error, Data> =>
pipe(
TE.tryCatch(
() => fetch('/api/data'),
(reason) => new Error(`Network request failed: ${reason}`)
)
);
type FetchError =
| { _tag: 'NetworkError'; cause: unknown }
| { _tag: 'ParseError'; cause: unknown }
| { _tag: 'ValidationError'; message: string };
fetchData = (): .<, > =>
(
.(
(),
(cause): ({ : , cause })
),
.(
.(
response.(),
(cause): ({ : , cause })
)
)
);
Pitfall 5: Overusing fromNullable
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';
};
const getName = (user: User | null): string => user?.name ?? 'Unknown';
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
const processUser = (input: string): User => {
const result = parseUser(input);
return (result as E.Right<User>).right;
};
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
const fetchUserApi = (id: string): TE.TaskEither<ApiError, UserDto> =>
pipe(
TE.tryCatch(
() => externalApiClient.getUser(id),
(e) => ({ type: 'api_error' as const, cause: e })
)
);
async function handleUserRequest(userId: string) {
const result = await fetchUserApi(userId)();
if (E.isRight(result)) {
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:
const unsafeUnwrap = <E, A>(either: E.Either<E, A>): A =>
pipe(
either,
E.getOrElseW((e) => {
throw e instanceof Error ? e : new Error(String(e));
})
);
const catchSync = <A>(f: () => A): E.Either<Error, A> =>
E.tryCatch(f, (e) => (e instanceof Error ? e : new Error(String(e))));
const fromPromise = <A>(p: Promise<A>): TE.TaskEither<Error, A> =>
TE.tryCatch(() => p, (e) => (e instanceof Error ? e : new Error(String(e))));
const toPromise = <E, A>(te: .<E, A>): <A> =>
().(E.( { 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
export const validateUser = (data: unknown): E.Either<ValidationError, User> => ;
export const enrichUser = (user: User): TE.TaskEither<Error, EnrichedUser> => ;
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;
}
export const getUser = (id: ): .<, > =>
(
(id),
.(validateUser >>> .),
.(enrichUser)
);
Strategy 4: Type-Driven Development
Use TypeScript's type system to guide the migration:
type OldGetUser = (id: string) => Promise<User | null>;
type NewGetUser = (id: string) => TE.TaskEither<UserError, User>;
const getUser: NewGetUser = (id) => ;
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:
function formatName(first: string, last: string): string {
return `${first} ${last}`;
}
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:
function sumLargeArray(numbers: number[]): number {
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
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:
app.get('/users/:id', async (req, res) => {
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:
const processOrder = (order: Order): TE.TaskEither<Error, Result> =>
pipe(
validateOrder(order),
TE.fromEither,
TE.flatMap(enrichOrder),
TE.flatMap(submitOrder)
);
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:
const name = user?.name ?? 'Anonymous';
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:
try {
await doSomething();
} catch (e) {
logger.error(e);
throw e;
}
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:
describe('UserService', () => {
it('creates a user', async () => {
const user = await createUser({ name: 'Alice' });
expect(user.name).toBe('Alice');
});
});
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, |
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.