fp-ts-do-notation
Master Do notation in fp-ts to write readable, sequential functional code without callback hell. Covers bind, apS, let, bindTo and real-world patterns.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Master Do notation in fp-ts to write readable, sequential functional code without callback hell. Covers bind, apS, let, bindTo and real-world patterns.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Product types, sum types, semigroups, monoids, Eq, Ord, and building custom type class instances for domain modeling in TypeScript
Practical async patterns using TaskEither - clean pipelines instead of try/catch hell, with real API examples
Functional programming patterns for Node.js/Deno backend development using fp-ts, ReaderTaskEither, and functional dependency injection
Practical patterns for composing functions in TypeScript using pipe, flow, and functional design principles
Everyday data transformations using functional patterns - arrays, objects, grouping, aggregation, and null-safe access
Stop throwing everywhere - handle errors as values using Either and TaskEither for cleaner, more predictable code
基于 SOC 职业分类
| name | fp-ts Do Notation |
| description | Master Do notation in fp-ts to write readable, sequential functional code without callback hell. Covers bind, apS, let, bindTo and real-world patterns. |
| version | 1.0.0 |
| author | fp-ts-skills |
| tags | ["fp-ts","functional-programming","typescript","do-notation","monads","taskEither","readerTaskEither","composition"] |
Do notation is fp-ts's answer to callback hell. It provides a way to write sequential, imperative-looking code while maintaining functional purity and type safety.
Without Do notation, chaining dependent operations leads to deeply nested code:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// BAD: Nested chain hell
const processOrder = (orderId: string) =>
pipe(
fetchOrder(orderId),
TE.chain((order) =>
pipe(
fetchUser(order.userId),
TE.chain((user) =>
pipe(
fetchInventory(order.productId),
TE.chain((inventory) =>
pipe(
validateStock(inventory, order.quantity),
TE.chain((validated) =>
pipe(
calculatePrice(order, user.discount),
TE.chain((price) =>
createInvoice(order, user, price) // Lost context of inventory!
)
)
)
)
)
)
)
)
)
)
Problems with nested chains:
Do notation flattens the structure and keeps all values in scope:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// GOOD: Flat, readable Do notation
const processOrder = (orderId: string) =>
pipe(
TE.Do,
TE.bind('order', () => fetchOrder(orderId)),
TE.bind('user', ({ order }) => fetchUser(order.userId)),
TE.bind('inventory', ({ order }) => fetchInventory(order.productId)),
TE.bind('validated', ({ inventory, order }) => validateStock(inventory, order.quantity)),
TE.bind('price', ({ order, user }) => calculatePrice(order, user.discount)),
TE.bind('invoice', ({ order, user, price }) => createInvoice(order, user, price))
)
Do - Starting PointDo creates an empty context object {} wrapped in your monad:
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
import * as O from 'fp-ts/Option'
TE.Do // TaskEither<never, {}>
E.Do // Either<never, {}>
O.Do // Option<{}>
bindTo - Initialize with First ValueUse bindTo when you already have a value and want to start Do notation:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// Instead of:
pipe(
TE.Do,
TE.bind('user', () => fetchUser(userId))
)
// Use bindTo for cleaner initialization:
pipe(
fetchUser(userId),
TE.bindTo('user'),
TE.bind('orders', ({ user }) => fetchOrders(user.id))
)
bindTo is semantically equivalent to TE.map(user => ({ user })) but more readable.
bind - Sequential Dependent OperationsUse bind when the next operation depends on previous values:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const getUserWithPosts = (userId: string) =>
pipe(
TE.Do,
TE.bind('user', () => fetchUser(userId)), // First: get user
TE.bind('posts', ({ user }) => fetchPosts(user.id)), // Then: use user.id
TE.bind('comments', ({ posts }) => // Then: use posts
TE.traverseArray(fetchComments)(posts.map(p => p.id))
)
)
Key characteristics of bind:
apS - Parallel Independent OperationsUse apS when operations are independent and can run in parallel:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const getDashboardData = (userId: string) =>
pipe(
TE.Do,
TE.bind('user', () => fetchUser(userId)),
// These three are INDEPENDENT - use apS for parallel execution
TE.apS('notifications', fetchNotifications(userId)),
TE.apS('settings', fetchSettings(userId)),
TE.apS('recentActivity', fetchRecentActivity(userId))
)
Key characteristics of apS:
let - Computed/Derived ValuesUse let for synchronous computations derived from existing values:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const processPayment = (orderId: string) =>
pipe(
TE.Do,
TE.bind('order', () => fetchOrder(orderId)),
TE.bind('user', ({ order }) => fetchUser(order.userId)),
// Computed values - no async operation needed
TE.let('subtotal', ({ order }) => order.items.reduce((sum, i) => sum + i.price, 0)),
TE.let('discount', ({ user, subtotal }) => subtotal * (user.discountPercent / 100)),
TE.let('total', ({ subtotal, discount }) => subtotal - discount),
TE.bind('payment', ({ total, user }) => chargeCard(user.paymentMethod, total))
)
Key characteristics of let:
| Situation | Use | Reason |
|---|---|---|
| Next operation needs previous result | bind | Sequential dependency |
| Operations are independent | apS | Can parallelize |
| Need to transform/compute | let | Synchronous, always succeeds |
| Starting with existing value | bindTo | Cleaner than Do + bind |
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
// SLOW: Sequential execution with bind (3 seconds total)
const slowDashboard = pipe(
TE.Do,
TE.bind('users', () => fetchUsers()), // 1 second
TE.bind('products', () => fetchProducts()), // 1 second (waits for users)
TE.bind('orders', () => fetchOrders()) // 1 second (waits for products)
)
// FAST: Parallel execution with apS (1 second total)
const fastDashboard = pipe(
TE.Do,
TE.apS('users', fetchUsers()), // 1 second
TE.apS('products', fetchProducts()), // 1 second (runs in parallel)
TE.apS('orders', fetchOrders()) // 1 second (runs in parallel)
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const getOrderDetails = (orderId: string) =>
pipe(
TE.Do,
// Sequential: need order first
TE.bind('order', () => fetchOrder(orderId)),
// Parallel: these only need order.userId and order.productId
TE.apS('user', pipe(
TE.Do,
TE.bind('order', () => fetchOrder(orderId)), // Need to refetch or...
)),
// Better pattern: bind first, then parallel for truly independent operations
)
// BETTER: Restructure for clarity
const getOrderDetailsBetter = (orderId: string) =>
pipe(
fetchOrder(orderId),
TE.bindTo('order'),
TE.bind('user', ({ order }) => fetchUser(order.userId)),
// Now these are independent of each other (but dependent on user/order)
TE.apS('shippingOptions', fetchShippingOptions(orderId)),
TE.apS('paymentMethods', fetchPaymentMethods(orderId)),
TE.let('canCheckout', ({ user, shippingOptions }) =>
user.verified && shippingOptions.length > 0
)
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
interface RegistrationInput {
email: string
password: string
name: string
}
interface User {
id: string
email: string
name: string
}
const registerUser = (input: RegistrationInput): TE.TaskEither<Error, User> =>
pipe(
TE.Do,
// Validate input (synchronous)
TE.bind('validated', () => pipe(
validateEmail(input.email),
E.chain(() => validatePassword(input.password)),
E.map(() => input),
TE.fromEither
)),
// Check if email exists (async)
TE.bind('emailAvailable', ({ validated }) =>
checkEmailAvailable(validated.email)
),
// Hash password (async, CPU-intensive)
TE.bind('hashedPassword', ({ validated }) =>
hashPassword(validated.password)
),
// Create user in database
TE.bind('user', ({ validated, hashedPassword }) =>
createUser({
email: validated.email,
name: validated.name,
passwordHash: hashedPassword
})
),
// Send welcome email (fire and forget, but still in chain)
TE.chainFirst(({ user }) => sendWelcomeEmail(user.email)),
// Return just the user
TE.map(({ user }) => user)
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
interface CheckoutResult {
orderId: string
paymentId: string
estimatedDelivery: Date
}
const checkout = (
userId: string,
cartId: string,
shippingAddressId: string
): TE.TaskEither<CheckoutError, CheckoutResult> =>
pipe(
TE.Do,
// Fetch required data in parallel where possible
TE.bind('cart', () => fetchCart(cartId)),
TE.bind('user', () => fetchUser(userId)),
TE.apS('shippingAddress', fetchAddress(shippingAddressId)),
// Validate cart has items
TE.bind('validatedCart', ({ cart }) =>
cart.items.length === 0
? TE.left(new CheckoutError('Cart is empty'))
: TE.right(cart)
),
// Check inventory for all items (parallel)
TE.bind('inventoryCheck', ({ validatedCart }) =>
pipe(
validatedCart.items,
TE.traverseArray((item) => checkInventory(item.productId, item.quantity))
)
),
// Calculate totals (synchronous)
TE.let('subtotal', ({ validatedCart }) =>
validatedCart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
),
TE.let('tax', ({ subtotal, shippingAddress }) =>
calculateTax(subtotal, shippingAddress.state)
),
TE.let('shippingCost', ({ shippingAddress, validatedCart }) =>
calculateShipping(shippingAddress, validatedCart.totalWeight)
),
TE.let('total', ({ subtotal, tax, shippingCost }) =>
subtotal + tax + shippingCost
),
// Process payment
TE.bind('payment', ({ user, total }) =>
processPayment(user.defaultPaymentMethod, total)
),
// Create order
TE.bind('order', ({ user, validatedCart, shippingAddress, payment, total }) =>
createOrder({
userId: user.id,
items: validatedCart.items,
shippingAddressId: shippingAddress.id,
paymentId: payment.id,
total
})
),
// Reserve inventory
TE.chainFirst(({ order, validatedCart }) =>
pipe(
validatedCart.items,
TE.traverseArray((item) => reserveInventory(item.productId, item.quantity, order.id))
)
),
// Clear cart
TE.chainFirst(({ cart }) => clearCart(cart.id)),
// Calculate delivery estimate
TE.let('estimatedDelivery', ({ shippingAddress }) =>
calculateDeliveryDate(shippingAddress)
),
// Return result
TE.map(({ order, payment, estimatedDelivery }) => ({
orderId: order.id,
paymentId: payment.id,
estimatedDelivery
}))
)
import { pipe } from 'fp-ts/function'
import * as RTE from 'fp-ts/ReaderTaskEither'
import * as TE from 'fp-ts/TaskEither'
// Define dependencies
interface Deps {
userRepo: UserRepository
orderRepo: OrderRepository
paymentService: PaymentService
emailService: EmailService
logger: Logger
}
// Use RTE.Do for dependency-injected workflows
const processRefund = (
orderId: string,
reason: string
): RTE.ReaderTaskEither<Deps, RefundError, RefundResult> =>
pipe(
RTE.Do,
// Access dependencies via RTE.asks
RTE.bind('deps', () => RTE.ask<Deps>()),
// Fetch order
RTE.bind('order', ({ deps }) =>
RTE.fromTaskEither(deps.orderRepo.findById(orderId))
),
// Validate refund is possible
RTE.bind('validatedOrder', ({ order }) =>
order.status !== 'completed'
? RTE.left(new RefundError('Order not eligible for refund'))
: RTE.right(order)
),
// Process refund with payment service
RTE.bind('refund', ({ deps, validatedOrder }) =>
RTE.fromTaskEither(
deps.paymentService.refund(validatedOrder.paymentId, validatedOrder.total)
)
),
// Update order status
RTE.bind('updatedOrder', ({ deps, validatedOrder, refund }) =>
RTE.fromTaskEither(
deps.orderRepo.update(validatedOrder.id, {
status: 'refunded',
refundId: refund.id,
refundReason: reason
})
)
),
// Fetch user for email
RTE.bind('user', ({ deps, validatedOrder }) =>
RTE.fromTaskEither(deps.userRepo.findById(validatedOrder.userId))
),
// Send notification (fire and forget)
RTE.chainFirst(({ deps, user, refund }) =>
RTE.fromTaskEither(
deps.emailService.sendRefundConfirmation(user.email, refund)
)
),
// Log the refund
RTE.chainFirst(({ deps, order, refund }) =>
RTE.fromTaskEither(
deps.logger.info('Refund processed', { orderId: order.id, refundId: refund.id })
)
),
// Return result
RTE.map(({ refund, updatedOrder }) => ({
refundId: refund.id,
orderId: updatedOrder.id,
amount: refund.amount,
status: 'completed'
}))
)
// Execute with dependencies
const runRefund = (deps: Deps, orderId: string, reason: string) =>
processRefund(orderId, reason)(deps)()
import { pipe } from 'fp-ts/function'
import * as E from 'fp-ts/Either'
import * as TE from 'fp-ts/TaskEither'
import * as A from 'fp-ts/Apply'
import { sequenceS } from 'fp-ts/Apply'
// For parallel validation that accumulates ALL errors (not short-circuit)
// Use Apply.sequenceS instead of Do notation
interface ValidationError {
field: string
message: string
}
type ValidationResult<A> = E.Either<ValidationError[], A>
const validateUserInput = (input: unknown): ValidationResult<ValidUser> => {
const validateField = <A>(
field: string,
value: unknown,
validator: (v: unknown) => E.Either<string, A>
): ValidationResult<A> =>
pipe(
validator(value),
E.mapLeft((message) => [{ field, message }])
)
// Use sequenceS with validation applicative to collect ALL errors
return pipe(
sequenceS(E.getApplicativeValidation(A.getSemigroup<ValidationError>()))({
email: validateField('email', input.email, validateEmail),
password: validateField('password', input.password, validatePassword),
age: validateField('age', input.age, validateAge),
name: validateField('name', input.name, validateName)
})
)
}
// For TaskEither with parallel execution AND error accumulation:
const validateUserAsync = (input: UserInput): TE.TaskEither<ValidationError[], ValidUser> =>
pipe(
sequenceS(TE.ApplicativePar)({
emailUnique: checkEmailUnique(input.email),
usernameAvailable: checkUsernameAvailable(input.username),
phoneValid: validatePhoneNumber(input.phone)
}),
TE.map(({ emailUnique, usernameAvailable, phoneValid }) => ({
...input,
emailVerified: emailUnique,
usernameVerified: usernameAvailable,
phoneVerified: phoneValid
}))
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as A from 'fp-ts/Array'
interface DashboardData {
user: User
stats: UserStats
recentOrders: Order[]
recommendations: Product[]
notifications: Notification[]
}
const loadDashboard = (userId: string): TE.TaskEither<Error, DashboardData> =>
pipe(
TE.Do,
// First, get user (required for everything)
TE.bind('user', () => fetchUser(userId)),
// These are all independent - parallel execution
TE.apS('stats', fetchUserStats(userId)),
TE.apS('recentOrders', fetchRecentOrders(userId)),
TE.apS('notifications', fetchNotifications(userId)),
// Recommendations depend on user preferences
TE.bind('recommendations', ({ user }) =>
fetchRecommendations(user.preferences)
),
// Enhance orders with product details (depends on recentOrders)
TE.bind('ordersWithProducts', ({ recentOrders }) =>
pipe(
recentOrders,
A.map((order) =>
pipe(
fetchProductDetails(order.productId),
TE.map((product) => ({ ...order, product }))
)
),
TE.sequenceArray
)
),
// Compute derived data
TE.let('unreadCount', ({ notifications }) =>
notifications.filter((n) => !n.read).length
),
TE.let('totalSpent', ({ recentOrders }) =>
recentOrders.reduce((sum, o) => sum + o.total, 0)
),
// Return final shape
TE.map(({ user, stats, ordersWithProducts, recommendations, notifications }) => ({
user,
stats,
recentOrders: ordersWithProducts,
recommendations,
notifications
}))
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const deleteAccount = (userId: string, confirmationCode: string) =>
pipe(
TE.Do,
TE.bind('user', () => fetchUser(userId)),
// Guard: Check confirmation code
TE.bind('confirmed', ({ user }) =>
confirmationCode === user.deleteConfirmationCode
? TE.right(true)
: TE.left(new Error('Invalid confirmation code'))
),
// Guard: Check no pending orders
TE.bind('pendingOrders', ({ user }) => fetchPendingOrders(user.id)),
TE.bind('canDelete', ({ pendingOrders }) =>
pendingOrders.length === 0
? TE.right(true)
: TE.left(new Error('Cannot delete account with pending orders'))
),
// Proceed with deletion
TE.bind('deleted', ({ user }) => deleteUserAccount(user.id))
)
Use chainFirst when you want to perform a side effect but keep the original value:
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
const createPost = (input: PostInput) =>
pipe(
TE.Do,
TE.bind('post', () => savePost(input)),
// Log creation (side effect, ignore result)
TE.chainFirst(({ post }) => logPostCreation(post.id)),
// Notify followers (side effect, ignore result)
TE.chainFirst(({ post }) => notifyFollowers(post.authorId, post.id)),
// Index for search (side effect, ignore result)
TE.chainFirst(({ post }) => indexForSearch(post)),
// Return just the post
TE.map(({ post }) => post)
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as O from 'fp-ts/Option'
const processOrder = (orderId: string, promoCode?: string) =>
pipe(
TE.Do,
TE.bind('order', () => fetchOrder(orderId)),
// Conditionally apply promo code
TE.bind('discount', ({ order }) =>
promoCode
? validatePromoCode(promoCode, order.total)
: TE.right(0)
),
TE.let('finalTotal', ({ order, discount }) => order.total - discount)
)
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as A from 'fp-ts/Array'
const processOrders = (orderIds: string[]) =>
pipe(
TE.Do,
// Fetch all orders in parallel
TE.bind('orders', () =>
pipe(
orderIds,
A.map(fetchOrder),
TE.sequenceArray
)
),
// Process each order
TE.bind('processed', ({ orders }) =>
pipe(
orders,
A.map(processOrder),
TE.sequenceArray
)
),
// Aggregate results
TE.let('summary', ({ processed }) => ({
total: processed.length,
successful: processed.filter((p) => p.status === 'success').length,
failed: processed.filter((p) => p.status === 'failed').length
}))
)
// SLOW: 3 sequential API calls
pipe(
TE.Do,
TE.bind('a', () => fetchA()), // 100ms
TE.bind('b', () => fetchB()), // 100ms
TE.bind('c', () => fetchC()) // 100ms
) // Total: 300ms
// FAST: 3 parallel API calls
pipe(
TE.Do,
TE.apS('a', fetchA()), // 100ms
TE.apS('b', fetchB()), // 100ms (parallel)
TE.apS('c', fetchC()) // 100ms (parallel)
) // Total: ~100ms
// WRONG: Using bind for pure computation
TE.bind('total', ({ items }) => TE.right(items.reduce((s, i) => s + i.price, 0)))
// RIGHT: Using let for pure computation
TE.let('total', ({ items }) => items.reduce((s, i) => s + i.price, 0))
// SLOW: N+1 queries
pipe(
TE.Do,
TE.bind('orders', () => fetchOrders(userId)),
TE.bind('products', ({ orders }) =>
pipe(
orders,
A.map((o) => fetchProduct(o.productId)), // N queries!
TE.sequenceArray
)
)
)
// FAST: Batch query
pipe(
TE.Do,
TE.bind('orders', () => fetchOrders(userId)),
TE.bind('products', ({ orders }) =>
fetchProductsByIds(orders.map((o) => o.productId)) // 1 query
)
)
// INEFFICIENT: Rebuilding large context
pipe(
TE.Do,
TE.bind('hugeData', () => fetchHugeData()),
TE.map(({ hugeData }) => ({ hugeData, processed: true })), // Copies hugeData
TE.bind('more', () => fetchMore()) // hugeData still in context
)
// BETTER: Extract what you need early
pipe(
TE.Do,
TE.bind('hugeData', () => fetchHugeData()),
TE.let('summary', ({ hugeData }) => summarize(hugeData)), // Extract summary
TE.map(({ summary }) => summary) // Drop hugeData from context
)
Do notation transforms deeply nested callback chains into flat, readable pipelines:
| Function | Purpose | When to Use |
|---|---|---|
Do | Start empty context | Beginning of chain |
bindTo | Start with value | When you have initial value |
bind | Sequential operation | Depends on previous values |
apS | Parallel operation | Independent of other values |
let | Pure computation | Derive values synchronously |
chainFirst | Side effect | Fire-and-forget operations |
Key principles:
bind for dependencies, apS for independencelet for pure computations, never bind with TE.rightsequenceArray/traverseArray for collectionschainFirst for side effects that shouldn't affect the resultDo notation is the key to writing maintainable fp-ts code. Master it, and functional programming becomes as readable as imperative code while retaining all its benefits.