fp-ts brings Haskell-style functional programming to TypeScript — Option<A> models nullable values without null checks: some(value) or none. Either<E, A> models typed errors: right(value) for success, left(error) for failure. pipe(value, f1, f2, f3) composes left-to-right. flow(f1, f2, f3) creates a composed function. TaskEither<E, A> handles async operations with typed errors — TE.tryCatch(() => fetch(...), toError). IO<A> encapsulates synchronous side effects. Reader<R, A> injects dependencies. A.sequence(TE.ApplicativePar)([te1, te2]) runs TaskEithers in parallel and collects results. Do notation with bind reads like imperative code while staying pure. Claude Code generates fp-ts pipelines, TaskEither async chains, Option/Either conversions, and Reader dependency injection patterns.
CLAUDE.md for fp-ts
## fp-ts Stack
- Version: fp-ts >= 2.16
- Option: O.some(v) / O.none — O.fold(() => default, v => v)(opt)
- Either: E.right(v) / E.left(err) — E.chain, E.map, E.mapLeft
- pipe: pipe(value, E.chain(f), E.map(g)) — left-to-right composition
- TaskEither: TE.tryCatch(() => asyncFn(), toError) — TE.chain, TE.map
- Parallel: TE.sequenceArray or A.traverse(TE.ApplicativePar)
- Do: pipe(TE.Do, TE.bind("user", () => fetchUser()), TE.bind("orders", ({ user }) => fetchOrders(user.id)))
- Reader: R.asks((deps: Deps) => deps.db) — inject at program edge
- Decode: io-ts or zod for runtime validation → Either
Option — Nullable Values
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { pipe } from "fp-ts/function"
// Option replaces null/undefined
type User = { id: string; name: string; email: string; role?: string }
// fromNullable wraps nullable values
function findUser(users: User[], id: string): O.Option<User> {
return O.fromNullable(users.find(u => u.id === id))
}
// Chain operations on present values
function getUserRole(users: User[], id: string): O.Option<string> {
return pipe(
findUser(users, id),
O.chain(user => O.fromNullable(user.role)),
O.map(role => role.toUpperCase()),
)
}
// Fold to extract value with default
function displayRole(users: User[], id: string): string {
return pipe(
getUserRole(users, id),
O.fold(
() => "No role assigned",
role => `Role: ${role}`,
),
)
}
// getOrElse — simpler default fallback
function getUsername(user: O.Option<User>): string {
return pipe(
user,
O.map(u => u.name),
O.getOrElse(() => "Anonymous"),
)
}
// sequence — all-or-nothing for arrays of Options
function getRequiredUsers(users: User[], ids: string[]): O.Option<User[]> {
return pipe(
ids,
A.map(id => findUser(users, id)),
O.sequenceArray,
O.map(arr => Array.from(arr)),
)
}
// fromPredicate — conditional wrapping
const activeUser = (user: User): O.Option<User> =>
pipe(user, O.fromPredicate(u => u.role !== undefined))
Either — Typed Errors
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
// Discriminated union for app errors
type AppError =
| { type: "NOT_FOUND"; message: string }
| { type: "VALIDATION"; field: string; message: string }
| { type: "UNAUTHORIZED"; message: string }
| { type: "DATABASE"; message: string; cause?: unknown }
const notFound = (msg: string): AppError => ({ type: "NOT_FOUND", message: msg })
const validation = (field: string, msg: string): AppError => ({ type: "VALIDATION", field, message: msg })
const unauthorized = (msg: string): AppError => ({ type: "UNAUTHORIZED", message: msg })
// Validate user input
function validateEmail(email: string): E.Either<AppError, string> {
const trimmed = email.trim().toLowerCase()
if (!trimmed) return E.left(validation("email", "Email is required"))
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
return E.left(validation("email", "Invalid email format"))
}
return E.right(trimmed)
}
function validatePassword(password: string): E.Either<AppError, string> {
if (password.length < 8) return E.left(validation("password", "Password must be at least 8 characters"))
if (!/[A-Z]/.test(password)) return E.left(validation("password", "Password must contain uppercase letter"))
return E.right(password)
}
// Combine validations
type RegisterInput = { email: string; password: string }
type ValidatedInput = { email: string; password: string }
function validateRegisterInput(input: RegisterInput): E.Either<AppError, ValidatedInput> {
return pipe(
E.Do,
E.bind("email", () => validateEmail(input.email)),
E.bind("password", () => validatePassword(input.password)),
)
}
// Chain auth check
function checkPermission(user: { role: string }, required: string): E.Either<AppError, void> {
return user.role === required || user.role === "admin"
? E.right(undefined)
: E.left(unauthorized(`Requires ${required} role`))
}
// Map error types for API responses
function toHttpError(error: AppError): { status: number; message: string } {
switch (error.type) {
case "NOT_FOUND": return { status: 404, message: error.message }
case "VALIDATION": return { status: 400, message: `${error.field}: ${error.message}` }
case "UNAUTHORIZED": return { status: 401, message: error.message }
case "DATABASE": return { status: 500, message: "Internal server error" }
}
}
TaskEither — Async Error Handling
import * as TE from "fp-ts/TaskEither"
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
type User = { id: string; name: string; email: string }
type Order = { id: string; userId: string; totalCents: number; status: string }
// Wrap async operations in TaskEither
function fetchUser(userId: string): TE.TaskEither<AppError, User> {
return TE.tryCatch(
async () => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<User>
},
(err): AppError => ({
type: "DATABASE",
message: "Failed to fetch user",
cause: err,
}),
)
}
function fetchOrders(userId: string): TE.TaskEither<AppError, Order[]> {
return TE.tryCatch(
async () => {
const res = await fetch(`/api/users/${userId}/orders`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<Order[]>
},
(err): AppError => ({ type: "DATABASE", message: "Failed to fetch orders", cause: err }),
)
}
function createOrder(data: { userId: string; totalCents: number }): TE.TaskEither<AppError, Order> {
return TE.tryCatch(
async () => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<Order>
},
(err): AppError => ({ type: "DATABASE", message: "Failed to create order", cause: err }),
)
}
// Do-notation — reads like imperative, stays pure
type UserOrderSummary = {
user: User
orders: Order[]
totalSpent: number
}
function getUserOrderSummary(userId: string): TE.TaskEither<AppError, UserOrderSummary> {
return pipe(
TE.Do,
TE.bind("user", () => fetchUser(userId)),
TE.bind("orders", ({ user }) => fetchOrders(user.id)),
TE.map(({ user, orders }) => ({
user,
orders,
totalSpent: orders.reduce((sum, o) => sum + o.totalCents, 0),
})),
)
}
// Parallel execution with sequenceArray
function fetchMultipleUsers(userIds: string[]): TE.TaskEither<AppError, User[]> {
return pipe(
userIds,
TE.traverseArray(userId => fetchUser(userId)),
TE.map(arr => Array.from(arr)),
)
}
// Conditional chain — only proceed if condition met
function placeOrderIfActive(
userId: string,
totalCents: number,
): TE.TaskEither<AppError, Order> {
return pipe(
fetchUser(userId),
TE.chainW(user =>
user.email.includes("@")
? createOrder({ userId: user.id, totalCents })
: TE.left<AppError>({ type: "UNAUTHORIZED", message: "User account not verified" }),
),
)
}
// Execute TaskEither — unwrap at program boundary
async function runGetSummary(userId: string) {
const result = await getUserOrderSummary(userId)()
if (E.isLeft(result)) {
const httpError = toHttpError(result.left)
return Response.json(httpError, { status: httpError.status })
}
return Response.json(result.right)
}
Reader — Dependency Injection
import * as R from "fp-ts/Reader"
import * as RTE from "fp-ts/ReaderTaskEither"
import { pipe } from "fp-ts/function"
// Dependencies interface
interface AppDeps {
db: {
users: { findById(id: string): Promise<User | null> }
orders: { findByUserId(id: string): Promise<Order[]> }
}
logger: { info(msg: string, data?: unknown): void; error(msg: string, err: unknown): void }
config: { maxOrdersPerUser: number }
}
// ReaderTaskEither — combine Reader + TaskEither
type RTE<A> = RTE.ReaderTaskEither<AppDeps, AppError, A>
function getUserRTE(userId: string): RTE<User> {
return RTE.asksTaskEitherW(deps =>
TE.tryCatch(
async () => {
const user = await deps.db.users.findById(userId)
if (!user) throw new Error("Not found")
return user
},
(err): AppError => ({ type: "NOT_FOUND", message: `User ${userId} not found` }),
),
)
}
function getOrdersRTE(userId: string): RTE<Order[]> {
return RTE.asksTaskEitherW(deps =>
TE.tryCatch(
() => deps.db.orders.findByUserId(userId),
(err): AppError => ({ type: "DATABASE", message: "Orders fetch failed", cause: err }),
),
)
}
// Compose with Reader
const getUserSummaryRTE = (userId: string): RTE<UserOrderSummary> =>
pipe(
RTE.Do,
RTE.bind("user", () => getUserRTE(userId)),
RTE.bind("orders", ({ user }) => getOrdersRTE(user.id)),
RTE.map(({ user, orders }) => ({
user,
orders,
totalSpent: orders.reduce((sum, o) => sum + o.totalCents, 0),
})),
)
// Inject dependencies at the edge
async function handleGetSummary(userId: string, deps: AppDeps) {
const result = await getUserSummaryRTE(userId)(deps)()
return E.fold(
(err: AppError) => Response.json(toHttpError(err), { status: toHttpError(err).status }),
(summary: UserOrderSummary) => Response.json(summary),
)(result)
}
For the Effect alternative when a more ergonomic functional effect system with built-in dependency injection (Context.Tag), structured concurrency, resource management, and a richer standard library than fp-ts are preferred — Effect (formerly Effect-TS) has superseded fp-ts for many functional TypeScript projects with a more approachable API and better tooling, see the Effect guide. For the neverthrow alternative when a lighter, more pragmatic Result type without full functional programming primitives is preferred — neverthrow provides ok/err, ResultAsync, and .andThen() chaining without the full fp-ts ecosystem, making it easier to adopt incrementally, see the neverthrow guide. The Claude Skills 360 bundle includes fp-ts skill sets covering Option, Either, TaskEither, and Reader patterns. Start with the free tier to try functional TypeScript generation.