| name | fp-refactor |
| version | 1.1.1 |
| description | Migrates existing imperative TypeScript—hidden throws, nullable returns, and Node err-first callbacks—onto fp-ts Option, Either, and TaskEither so callers must handle absence and failure. Use when throws, nulls, or callbacks hide failure from the compiler and a staged conversion is justified. Not for greenfield Either snippets or profiler-hot inner loops. Never convert stable readable code solely for style, or introduce fp-ts where the team cannot maintain it. |
| risk | safe |
| source | openrouter-deepsearch |
| date_added | 2026-06-16T00:00:00.000Z |
Refactoring Imperative Code to fp-ts
This skill provides patterns and strategies for migrating existing imperative TypeScript code to fp-ts functional patterns.
The core idea: imperative code expresses failure, absence, and asynchrony implicitly — exceptions are thrown but not visible in a signature, null can appear anywhere, and callbacks bury the error channel in an argument. fp-ts moves those concerns into the type system. Either<E, A> says "this can fail with E", Option<A> says "this value may be absent", and TaskEither<E, A> says "this async computation can fail with E". Once a concern is in the type, the compiler — not a code reviewer or a production incident — forces every caller to deal with it.
When to Use
Reach for these patterns when the implicitness described above is actively causing pain:
- You are migrating an imperative TypeScript codebase toward fp-ts. A consistent functional style across modules is what makes the patterns compose; one-off usage rarely pays for the learning curve.
- Failure or absence is being passed around invisibly. If functions throw, return
null/undefined, or take Node-style (err, data) callbacks, callers have no compile-time reminder to handle the bad path. Encoding it as Either, Option, or TaskEither turns those silent omissions into type errors.
- You need composition, not just one example. When several fallible steps must be chained (parse, then validate, then fetch, then write),
pipe with flatMap keeps the happy path linear and short-circuits on the first failure — replacing nested try/catch and if (x === null) return ladders.
- You want migration guidance and tradeoffs, not isolated snippets. The value here is knowing when the conversion clarifies intent versus when it only adds ceremony.
When Not to Use
Functional abstractions are not free, so skip them when the cost outweighs the clarity they buy:
- Performance-critical hot paths. Every
pipe, map, and flatMap allocates closures and intermediate wrappers. In a tight loop or a latency-sensitive inner function, that overhead can matter; measure before converting, and prefer plain code where the profiler points.
- Error types that carry no meaning. If the only sensible response to a failure is "log and crash", a single
try/catch at the boundary communicates that just as well as Either, without asking every reader to learn the abstraction.
- Teams unfamiliar with functional programming. fp-ts has a steep vocabulary (
Functor, Monad, Kleisli). Introducing it where the team cannot maintain it trades a runtime risk for a comprehension risk, which is usually a worse deal. Invest in shared understanding first.
- Code that is already simple, readable, and stable. Refactoring well-understood imperative code purely for stylistic consistency adds churn and review burden with no behavioural gain. Let the benefit (a clearer signature, a bug class the compiler can now catch) justify the change.
Prerequisites
- fp-ts fundamentals. Comfort with
pipe, map, flatMap/chain, getOrElse, and the difference between Option, Either, Task, and TaskEither. Without this, the converted code reads as noise rather than intent.
- TypeScript's type system. Discriminated unions (the
_tag pattern), generics, and readonly modifiers are what give the converted code its compile-time guarantees; weak typing here undermines the whole point.
- Refactoring discipline. Knowing how to convert incrementally and verify behaviour is preserved at each step, so a migration does not silently change semantics while changing style.
Procedure
Step 1: Find the implicit failure points
Scan for throw, try/catch, null/undefined returns, and callback signatures. These are where types are currently lying about what a function can do, and therefore where encoding the failure pays off most.
Step 2: Pick the smallest type that fits
Use Option for "may be absent" (no error detail needed), Either for synchronous "may fail with a reason", and TaskEither for asynchronous "may fail with a reason". Choosing the narrowest type keeps signatures honest and avoids dragging a Task through purely synchronous code.
Step 3: Model errors as discriminated unions, not bare Error
A type such as { readonly _tag: 'JsonParseError'; readonly input: string; readonly cause: string } lets callers switch on _tag and handle each case exhaustively; a bare Error forces brittle string-matching on .message. This is what makes the typed error channel actually useful downstream.
Step 4: Compose with pipe, validate at the edges
Keep the happy path linear inside pipe, and put strict parameter validation (fromPredicate) at the entry of each function so bad input becomes a typed failure instead of a thrown exception deeper in the stack.
Step 5: Test both branches
A converted function now has a success and a failure value; assert on both, since the compiler guarantees the shape but not that you produce the right error in the right situation.
Step 6: Record the reasoning, not just the diff
Note why a given function was converted (which bug class it now prevents) so future maintainers can tell deliberate functional code from cargo-culted ceremony.
Example: Converting try-catch to Either/TaskEither
A function typed (input: string) => unknown that throws is doubly dishonest — the return type hides the failure entirely, and inside the catch the error is typed unknown, so you can only stringify it. Either<E, A> puts failure in the return type, and a discriminated error type preserves enough structure for callers to react to specific failures.
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
interface JsonParseError {
readonly _tag: 'JsonParseError';
readonly input: string;
readonly cause: string;
}
const toJsonParseError = (input: string) => (cause: unknown): JsonParseError => ({
_tag: 'JsonParseError',
input,
cause: cause instanceof Error ? cause.message : String(cause),
});
function parseJsonImperative(): {
{
.(input) ;
} (error) {
(
,
);
}
}
parseJson = (: ): E.<, > =>
E.(
.(input) ,
(input),
);
For asynchronous work, the same idea uses TaskEither<E, A>: a lazy async value that, when run, resolves to either a typed error or a success. Nothing executes until the TaskEither is invoked, which keeps it referentially transparent and composable.
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
interface HttpError {
readonly _tag: 'HttpError';
readonly url: string;
readonly status?: number;
readonly cause: string;
}
const toHttpError = (url: string) => (cause: unknown): HttpError => ({
_tag: 'HttpError',
url,
cause: cause instanceof Error ? cause.message : String(cause),
});
const ensureOk = (url: string) => (response: Response): TE.TaskEither<HttpError, Response> =>
response.ok
? .(response)
: .({
: ,
url,
: response.,
: ,
});
fetchJson = (: ): .<, > =>
(
.( (url), (url)),
.((url)),
.(
.( response.() <>, (url)),
),
);
Example: Converting null checks to Option
A signature like (config: Config) => string | null pushes the same if (x === null) guard onto every caller, and it only takes one forgotten guard to ship a Cannot read properties of null bug. Option<string> makes "absent" a value you compose with map/filter/getOrElse; the compiler will not let you reach the inner string without first handling None.
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
interface DatabaseCredentials {
readonly user: string;
readonly password: string;
}
interface DatabaseConfig {
readonly host: string;
readonly port: number;
readonly name: string;
readonly credentials?: DatabaseCredentials;
}
interface Config {
readonly database?: DatabaseConfig;
}
function getDatabaseUrlImperative(config: Config): string | null {
if (!config.database) {
return null;
}
const { host, port, name, credentials } = config.database;
if (host.length === || port <= || name. === ) {
;
}
auth = credentials ? : ;
;
}
buildDatabaseUrl = (: ): {
: = (
O.(db.),
O.( ),
O.( ),
);
;
};
getDatabaseUrl = (: ): O.<> =>
(
O.(config.),
O.(
(: ):
db.. > && db. > && db.. > ,
),
O.(buildDatabaseUrl),
);
Example: Converting callbacks to TaskEither
Node-style callbacks (err: Error | null, data: string | null) => void resist composition: you nest them to sequence work, you can forget to return after invoking the callback (running the rest of the function anyway), and TypeScript cannot force a caller to check err before using data. TaskEither<E, A> replaces the callback with a single composable value and makes the error path part of the type.
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
import * as fs from 'node:fs';
interface FileReadError {
readonly _tag: 'FileReadError';
readonly path: string;
readonly cause: string;
}
const toFileReadError = (path: string) => (cause: unknown): FileReadError => ({
_tag: 'FileReadError',
path,
cause: cause instanceof Error ? cause.message : String(cause),
});
function readFileCallback(
path: string,
callback: (error: Error | null, data: string | null) => void,
): {
fs.(path, , {
(err !== ) {
(err, );
;
}
(, data);
});
}
readFile = (: ): .<, > =>
(
path,
.(
(: ): candidate.(). > ,
(candidate)( ()),
),
.(
.(
fs..(validPath, ),
(path),
),
),
);
Pitfalls
- Performance on hot paths. Every
pipe, map, and flatMap allocates closures and intermediate wrappers. In tight loops or latency-sensitive inner functions, that overhead can matter. Measure before converting; prefer plain code where the profiler points.
- Error types that carry no meaning. If the only sensible response to a failure is "log and crash", a single
try/catch at the boundary communicates that just as well as Either, without asking every reader to learn the abstraction.
- Team unfamiliarity with functional programming. fp-ts has a steep vocabulary (
Functor, Monad, Kleisli). Introducing it where the team cannot maintain it trades a runtime risk for a comprehension risk. Invest in shared understanding first.
- Refactoring stable, readable code for style alone. Refactoring well-understood imperative code purely for stylistic consistency adds churn and review burden with no behavioural gain. Let the benefit (a clearer signature, a bug class the compiler can now catch) justify the change.
- Swallowing errors with
getOrElse/fold. Confirm there are no getOrElse/fold calls that quietly swallow real errors — the compiler forces you to handle the bad branch, but it does not stop you from handling it badly.
- Hidden
throw escaping the typed channel. Caught values are typed unknown and must be narrowed defensively; thrown values from third-party calls must be wrapped via tryCatch rather than escaping the typed channel.
- Using
any instead of unknown. A thrown value is not guaranteed to be an Error instance. Always normalise via cause instanceof Error ? cause.message : String(cause) rather than casting to any.
Verification
After a conversion, confirm the refactor actually bought something rather than just changing shape:
Related Skills
- fp-ts fundamentals — Comfort with
pipe, map, flatMap/chain, getOrElse, and the difference between Option, Either, Task, and TaskEither.
- TypeScript's type system — Discriminated unions (the
_tag pattern), generics, and readonly modifiers are what give the converted code its compile-time guarantees.
- Refactoring discipline — Knowing how to convert incrementally and verify behaviour is preserved at each step, so a migration does not silently change semantics while changing style.