fp-ts Do Notation Guide
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.
The Problem: Callback Hell in Functional Code
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:
-
Poor readability - Logic is buried in nesting
-
Lost context - Earlier values may not be accessible in inner scopes
-
Difficult refactoring - Adding/removing steps requires restructuring
-
Hard to parallelize - Everything looks sequential
The Solution: Do Notation
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)) )
Core Do Notation Functions
Do
- Starting Point
Do 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 Value
Use 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 Operations
Use 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 :
-
Operations execute sequentially
-
Each step has access to all previous values
-
Short-circuits on first error (for Either/TaskEither)
-
The callback receives the accumulated context object
apS
- Parallel Independent Operations
Use 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 :
-
Operations can execute in parallel (with TaskEither)
-
The value is computed immediately (not lazily)
-
No access to previous context values
-
Errors are collected or short-circuit depending on the applicative
let
- Computed/Derived Values
Use 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 :
-
Synchronous pure computation
-
Has access to all previous values
-
Cannot fail (for error types)
-
Use for transformations, calculations, formatting
bind vs apS: When to Use Which
Decision Guide
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
Performance: Sequential vs Parallel
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) )
Mixed Pattern: Sequential Then 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 ) )
Real-World Examples
Example 1: User Registration Flow
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) )
Example 2: E-commerce Checkout
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
}))
)
Example 3: ReaderTaskEither with Dependency Injection
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)()
Example 4: Validation with Accumulated Errors
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 })) )
Example 5: Complex Data Aggregation
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
}))
)
Common Patterns and Tips
Pattern 1: Early Return / Guard Clauses
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)) )
Pattern 2: Optional Operations with chainFirst
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) )
Pattern 3: Conditional Binding
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) )
Pattern 4: Working with Arrays in Do
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 })) )
Performance Considerations
- Prefer apS for Independent Operations
// 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
- Use let for Pure Computations
// 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))
- Batch Database Operations
// 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 ) )
- Avoid Rebuilding Context
// 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 )
Summary
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:
-
Use bind for dependencies, apS for independence
-
Use let for pure computations, never bind with TE.right
-
Keep the context lean - don't accumulate unnecessary data
-
Combine with sequenceArray /traverseArray for collections
-
Use chainFirst for side effects that shouldn't affect the result
Do 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.