fp-ts do notation

fp-ts Do Notation Guide

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "fp-ts do notation" with this command: npx skills add whatiskadudoing/fp-ts-skills/whatiskadudoing-fp-ts-skills-fp-ts-do-notation

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

  1. 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

  1. 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))

  1. 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 ) )

  1. 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.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

fp-ts-backend

No summary provided by upstream source.

Repository SourceNeeds Review
General

practical error handling with fp-ts

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-refactor

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-immutable

No summary provided by upstream source.

Repository SourceNeeds Review