- 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.
## When to Use
- You are refactoring an existing imperative TypeScript codebase toward fp-ts patterns.
- The task involves converting `try/catch`, null checks, callbacks, DI, or loops into functional equivalents.
- You need migration guidance and tradeoffs, not just isolated fp-ts examples.
## 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<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)
```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<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
1. **Identify the error type**: Determine what errors can occur and create appropriate error types
2. **Change return type**: From `T` to `Either<E, T>` 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<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)
```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<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:
```typescript
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)
```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<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)
```typescript
interface User {
id: string;
name: string;
email: string;
}
View on GitHub