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 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}`);
}
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;
}
}
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
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)
);
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)
);
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; }));
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: