| name | Managing Side Effects Functionally |
| description | Master functional approaches to side effects including IO types, effect isolation, idempotent operations, and quarantining impure code - essential patterns for reliable TypeScript applications |
| version | 1.0.0 |
| author | Claude |
| tags | ["functional-programming","typescript","javascript","side-effects","io-monad","purity","dependency-injection","idempotence","fp-ts"] |
Managing Side Effects Functionally
This skill covers functional programming techniques for handling side effects. Side effects are unavoidable in real programs - they're how we interact with the world. The goal isn't to eliminate them, but to control, isolate, and make them predictable.
Why Side Effect Management Matters
Uncontrolled side effects cause:
- Unpredictable behavior: Same function produces different results
- Testing nightmares: Must mock globals, databases, time, random
- Race conditions: Async operations interfere with each other
- Hidden dependencies: Code behavior depends on invisible state
- Debugging difficulty: Can't reproduce issues reliably
Functional effect management provides:
- Predictable programs: Effects happen when and where you expect
- Easy testing: Pure core with thin impure shell
- Composable operations: Build complex effects from simple pieces
- Explicit dependencies: No hidden state or implicit coupling
1. What Are Side Effects?
A function has a side effect if it does anything observable besides returning a value. Side effects come in two forms:
Side Inputs (Causes)
When a function reads from something other than its parameters:
let globalConfig = { apiUrl: 'https://api.example.com' }
const getApiUrl = (): string => globalConfig.apiUrl
const isExpired = (expiryDate: Date): boolean =>
expiryDate < new Date()
const generateId = (): string =>
`id-${Math.random().toString(36).slice(2)}`
const getInputValue = (id: string): string =>
(document.getElementById(id) as HTMLInputElement)?.value ?? ''
const getDatabaseUrl = (): string =>
process.env.DATABASE_URL ?? 'localhost:5432'
Side Outputs (Effects)
When a function causes observable changes outside its scope:
const logUser = (user: User): void => {
console.log(`User: ${user.name}`)
}
const addItem = (arr: string[], item: string): void => {
arr.push(item)
}
const saveUser = async (user: User): Promise<void> => {
await database.insert('users', user)
}
const sendAnalytics = async (event: AnalyticsEvent): Promise<void> => {
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(event),
})
}
let requestCount = 0
const trackRequest = (): void => {
requestCount += 1
}
const showMessage = (message: string): void => {
document.getElementById('output')!.textContent = message
}
Why Both Are Problems
const config = { multiplier: 2 }
const calculate = (x: number): number => x * config.multiplier
calculate(5)
config.multiplier = 3
calculate(5)
const items: string[] = ['a', 'b']
const process = (arr: string[]) => {
arr.push('c')
return arr.length
}
process(items)
console.log(items)
let cache: Record<string, User> = {}
const getUser = async (id: string): Promise<User> => {
if (cache[id]) return cache[id]
const user = await fetchUser(id)
cache[id] = user
return user
}
The Referential Transparency Test
A function is pure (side-effect free) if you can replace any call with its result without changing program behavior:
const double = (x: number): number => x * 2
const result = double(5) + double(5)
let counter = 0
const increment = (): number => {
counter += 1
return counter
}
const result = increment() + increment()
2. Isolating Side Effects at Program Boundaries
The functional architecture pattern: Pure Core, Impure Shell
The Pattern
┌─────────────────────────────────────────┐
│ Impure Shell │
│ ┌───────────────────────────────────┐ │
│ │ Pure Core │ │
│ │ │ │
│ │ - Business logic │ │
│ │ - Data transformations │ │
│ │ - Validation │ │
│ │ - All decisions │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ - HTTP requests │
│ - Database operations │
│ - File system │
│ - User input/output │
│ - Logging │
│ - Time/randomness │
└─────────────────────────────────────────┘
Before: Mixed Pure and Impure
const processOrder = async (orderId: string): Promise<void> => {
const order = await database.findOrder(orderId)
console.log(`Processing order ${orderId}`)
const discount = order.items.length > 5 ? 0.1 : 0
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0)
const total = subtotal * (1 - discount)
const processedAt = new Date()
await database.updateOrder(orderId, { total, processedAt, status: 'processed' })
await emailService.send(order.customerEmail, `Your order total: $${total}`)
}
After: Pure Core, Impure Shell
interface OrderItem {
price: number
quantity: number
}
interface Order {
id: string
customerEmail: string
items: OrderItem[]
}
interface ProcessedOrder {
orderId: string
subtotal: number
discount: number
total: number
processedAt: Date
}
const calculateDiscount = (itemCount: number): number =>
itemCount > 5 ? 0.1 : 0
const calculateOrderTotals = (
order: Order,
now: Date
): ProcessedOrder => {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
const discount = calculateDiscount(order.items.length)
const total = subtotal * (1 - discount)
return {
orderId: order.id,
subtotal,
discount,
total,
processedAt: now,
}
}
const formatOrderEmail = (processed: ProcessedOrder): string =>
`Your order total: $${processed.total.toFixed(2)}`
const processOrder = async (orderId: string): Promise<void> => {
const order = await database.findOrder(orderId)
const processed = calculateOrderTotals(order, new Date())
const emailContent = formatOrderEmail(processed)
await database.updateOrder(orderId, {
total: processed.total,
processedAt: processed.processedAt,
status: 'processed',
})
await emailService.send(order.customerEmail, emailContent)
console.log(`Processed order ${orderId}`)
}
describe('calculateOrderTotals', () => {
it('applies 10% discount for orders with more than 5 items', () => {
const order: Order = {
id: '123',
customerEmail: 'test@example.com',
items: Array(6).fill({ price: 10, quantity: 1 }),
}
const now = new Date('2024-01-01')
const result = calculateOrderTotals(order, now)
expect(result.discount).toBe(0.1)
expect(result.total).toBe(54)
})
})
Program Entry Points as Impure Shell
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import { validateConfig, processData, formatOutput } from './core'
import { readConfigFile, fetchInputData, writeOutput, logError } from './adapters'
const main = async (): Promise<void> => {
const result = await pipe(
readConfigFile('./config.json'),
TE.chainEitherK(validateConfig),
TE.chain(config => fetchInputData(config.dataUrl)),
TE.map(processData),
TE.map(formatOutput),
TE.chain(writeOutput),
)()
if (result._tag === 'Left') {
logError(result.left)
process.exit(1)
}
console.log('Done!')
}
main()
React: Pure Components, Effects at Boundaries
interface UserCardProps {
user: User
onEdit: (id: string) => void
onDelete: (id: string) => void
}
const UserCard: React.FC<UserCardProps> = ({ user, onEdit, onDelete }) => (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
)
const UserCardContainer: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false))
}, [userId])
const handleEdit = useCallback((id: string) => {
navigate(`/users/${id}/edit`)
}, [])
const handleDelete = useCallback(async (id: string) => {
await deleteUser(id)
navigate('/users')
}, [])
if (loading) return <Spinner />
if (!user) return <NotFound />
return <UserCard user={user} onEdit={handleEdit} onDelete={handleDelete} />
}
3. Idempotent Operations for Async Safety
An operation is idempotent if performing it multiple times has the same effect as performing it once. This is crucial for async safety.
Why Idempotence Matters
const incrementCounter = async (): Promise<void> => {
const current = await database.get('counter')
await database.set('counter', current + 1)
}
const setCounter = async (value: number): Promise<void> => {
await database.set('counter', value)
}
Patterns for Idempotence
1. Use Idempotency Keys
interface PaymentRequest {
idempotencyKey: string
amount: number
currency: string
customerId: string
}
const processPayment = async (request: PaymentRequest): Promise<PaymentResult> => {
const existing = await database.findPayment(request.idempotencyKey)
if (existing) {
return existing
}
const result = await paymentGateway.charge(request)
await database.savePayment(request.idempotencyKey, result)
return result
}
const pay = async () => {
const request: PaymentRequest = {
idempotencyKey: `pay-${orderId}-${Date.now()}`,
amount: 99.99,
currency: 'USD',
customerId: user.id,
}
return await retryWithBackoff(() => processPayment(request))
}
2. Use Conditional Updates
const addToBalance = async (userId: string, amount: number): Promise<void> => {
await database.query(
'UPDATE accounts SET balance = balance + $1 WHERE user_id = $2',
[amount, userId]
)
}
interface BalanceUpdate {
userId: string
newBalance: number
expectedVersion: number
}
const updateBalance = async (update: BalanceUpdate): Promise<boolean> => {
const result = await database.query(
`UPDATE accounts
SET balance = $1, version = version + 1
WHERE user_id = $2 AND version = $3`,
[update.newBalance, update.userId, update.expectedVersion]
)
return result.rowCount > 0
}
const transferFunds = async (
fromId: string,
toId: string,
amount: number
): Promise<Either<TransferError, void>> => {
const [from, to] = await Promise.all([
database.getAccount(fromId),
database.getAccount(toId),
])
if (from.balance < amount) {
return E.left({ type: 'InsufficientFunds' })
}
const fromUpdated = await updateBalance({
userId: fromId,
newBalance: from.balance - amount,
expectedVersion: from.version,
})
if (!fromUpdated) {
return E.left({ type: 'ConcurrentModification', account: 'source' })
}
const toUpdated = await updateBalance({
userId: toId,
newBalance: to.balance + amount,
expectedVersion: to.version,
})
if (!toUpdated) {
await updateBalance({
userId: fromId,
newBalance: from.balance,
expectedVersion: from.version + 1,
})
return E.left({ type: 'ConcurrentModification', account: 'destination' })
}
return E.right(undefined)
}
3. Use PUT Semantics Over POST
const createOrder = async (data: OrderData): Promise<Order> => {
const id = generateId()
return await database.insert({ id, ...data })
}
const upsertOrder = async (id: string, data: OrderData): Promise<Order> => {
return await database.upsert({ id, ...data })
}
const placeOrder = async (items: CartItem[]): Promise<Order> => {
const orderId = `order-${userId}-${Date.now()}`
return await api.put(`/orders/${orderId}`, { items })
}
4. Design for "At Least Once" Delivery
interface OrderMessage {
messageId: string
orderId: string
action: 'process' | 'ship' | 'cancel'
}
const handleOrderMessage = async (message: OrderMessage): Promise<void> => {
const alreadyProcessed = await messageStore.exists(message.messageId)
if (alreadyProcessed) {
console.log(`Message ${message.messageId} already processed, skipping`)
return
}
switch (message.action) {
case 'process':
await processOrder(message.orderId)
break
case 'ship':
await shipOrder(message.orderId)
break
case 'cancel':
await cancelOrder(message.orderId)
break
}
await messageStore.markProcessed(message.messageId)
}
const shipOrder = async (orderId: string): Promise<void> => {
const order = await database.getOrder(orderId)
if (order.status === 'shipped') {
return
}
if (order.status !== 'processed') {
throw new InvalidStateError(`Cannot ship order in ${order.status} state`)
}
await shippingService.createShipment(order)
await database.updateOrder(orderId, { status: 'shipped' })
}
Race Condition Mitigation
const safeIncrement = async (key: string): Promise<number> => {
return await database.transaction(async (tx) => {
const current = await tx.get(key)
const newValue = (current ?? 0) + 1
await tx.set(key, newValue)
return newValue
})
}
const compareAndSwap = async <T>(
key: string,
expectedValue: T,
newValue: T
): Promise<boolean> => {
const result = await redis.eval(`
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2])
return 1
else
return 0
end
`, 1, key, JSON.stringify(expectedValue), JSON.stringify(newValue))
return result === 1
}
import { Mutex } from 'async-mutex'
const orderMutexes = new Map<string, Mutex>()
const getMutex = (orderId: string): Mutex => {
if (!orderMutexes.has(orderId)) {
orderMutexes.set(orderId, new Mutex())
}
return orderMutexes.get(orderId)!
}
const processOrderSafely = async (orderId: string): Promise<void> => {
const mutex = getMutex(orderId)
await mutex.runExclusive(async () => {
await processOrder(orderId)
})
}
4. IO Type for Synchronous Effects
The IO type represents a synchronous computation that may have side effects. It's a function that takes no arguments and returns a value.
What is IO?
type IO<A> = () => A
const getCurrentTime: IO<Date> = () => new Date()
const getRandomNumber: IO<number> = () => Math.random()
const readEnvVar = (name: string): IO<string | undefined> =>
() => process.env[name]
const time1 = getCurrentTime()
const time2 = getCurrentTime()
Why Wrap Effects in IO?
const logAndReturn = <A>(a: A): A => {
console.log(a)
return a
}
const logAndReturn = <A>(a: A): IO<A> => () => {
console.log(a)
return a
}
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
const program: IO<void> = pipe(
getCurrentTime,
IO.map(date => date.toISOString()),
IO.chain(iso => () => console.log(`Current time: ${iso}`))
)
program()
fp-ts IO Module
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
const pureValue: IO.IO<number> = IO.of(42)
const doubled: IO.IO<number> = pipe(
IO.of(21),
IO.map(n => n * 2)
)
const getAndLog: IO.IO<void> = pipe(
() => new Date(),
IO.chain(date => () => console.log(date.toISOString()))
)
const loggedValue: IO.IO<number> = pipe(
IO.of(42),
IO.chainFirst(n => () => console.log(`Value is: ${n}`)),
IO.map(n => n + 1)
)
const combined: IO.IO<string> = pipe(
IO.Do,
IO.bind('time', () => () => new Date()),
IO.bind('random', () => () => Math.random()),
IO.map(({ time, random }) => `${time.toISOString()}: ${random}`)
)
Practical IO Examples
import * as IO from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'
const log = (message: string): IO.IO<void> =>
() => console.log(message)
const warn = (message: string): IO.IO<void> =>
() => console.warn(message)
const error = (message: string): IO.IO<void> =>
() => console.error(message)
const getEnv = (key: string): IO.IO<string | undefined> =>
() => process.env[key]
const requireEnv = (key: string): IO.IO<string> =>
() => {
const value = process.env[key]
if (!value) throw new Error(`Missing env var: ${key}`)
return value
}
const getElementById = (id: string): IO.IO<HTMLElement | null> =>
() => document.getElementById(id)
const setTextContent = (element: HTMLElement, text: string): IO.IO<void> =>
() => { element.textContent = text }
const random: IO.IO<number> = () => Math.random()
const now: IO.IO<Date> = () => new Date()
const displayCurrentTime = (elementId: string): IO.IO<void> =>
pipe(
IO.Do,
IO.bind('element', () => getElementById(elementId)),
IO.bind('time', () => now),
IO.chain(({ element, time }) =>
element
? setTextContent(element, time.toLocaleTimeString())
: log(`Element ${elementId} not found`)
)
)
const program = displayCurrentTime('clock')
program()
IO vs Immediate Effects
class Logger {
constructor() {
console.log('Logger initialized')
}
log(msg: string): void {
console.log(msg)
}
}
const logger = new Logger()
const createLogger = (name: string): IO.IO<Logger> => () => {
console.log(`Logger ${name} initialized`)
return {
log: (msg: string) => console.log(`[${name}] ${msg}`),
}
}
const loggerProgram = createLogger('app')
const logger = loggerProgram()
5. Quarantining Impure Code
Quarantining means isolating impure code into specific modules that are clearly marked as "effectful", keeping the rest of your codebase pure.
Module Structure for Quarantine
src/
├── core/ # PURE: Business logic
│ ├── domain/
│ │ ├── user.ts # User type and pure functions
│ │ ├── order.ts # Order type and calculations
│ │ └── validation.ts # Pure validation functions
│ └── services/
│ ├── pricing.ts # Pure pricing calculations
│ └── discount.ts # Pure discount rules
│
├── adapters/ # IMPURE: External world
│ ├── database/
│ │ ├── userRepo.ts # Database operations
│ │ └── orderRepo.ts
│ ├── http/
│ │ ├── userApi.ts # HTTP clients
│ │ └── paymentApi.ts
│ ├── logging/
│ │ └── logger.ts # Console/file logging
│ └── config/
│ └── env.ts # Environment variables
│
├── effects/ # Effect type definitions
│ ├── io.ts # IO effect utilities
│ └── task.ts # Async effect utilities
│
└── main.ts # IMPURE: Entry point, wires everything
Pure Domain Layer
export interface OrderItem {
productId: string
name: string
price: number
quantity: number
}
export interface Order {
id: string
customerId: string
items: readonly OrderItem[]
status: OrderStatus
createdAt: Date
}
export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered'
export const calculateSubtotal = (items: readonly OrderItem[]): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
export const calculateTax = (subtotal: number, taxRate: number): number =>
subtotal * taxRate
export const calculateTotal = (
subtotal: number,
tax: number,
discount: number
): number =>
Math.max(0, subtotal + tax - discount)
export const canShip = (order: Order): boolean =>
order.status === 'confirmed' && order.items.length > 0
export const canCancel = (order: Order): boolean =>
order.status === 'pending' || order.status === 'confirmed'
export const confirmOrder = (order: Order): Order => ({
...order,
status: 'confirmed',
})
export const shipOrder = (order: Order): Order => ({
...order,
status: 'shipped',
})
Impure Adapter Layer
import { Order, OrderStatus } from '../../core/domain/order'
import * as TE from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'
export interface OrderRepository {
findById: (id: string) => TE.TaskEither<DatabaseError, Order>
findByCustomer: (customerId: string) => TE.TaskEither<DatabaseError, Order[]>
save: (order: Order) => TE.TaskEither<DatabaseError, Order>
updateStatus: (id: string, status: OrderStatus) => TE.TaskEither<DatabaseError, void>
}
export type DatabaseError =
| { type: 'NotFound'; id: string }
| { type: 'ConnectionError'; message: string }
| { type: 'QueryError'; message: string }
export const createOrderRepository = (
pool: DatabasePool
): OrderRepository => ({
findById: (id) =>
TE.tryCatch(
async () => {
const result = await pool.query('SELECT * FROM orders WHERE id = $1', [id])
if (result.rows.length === 0) {
throw { type: 'NotFound', id }
}
return mapRowToOrder(result.rows[0])
},
(error): DatabaseError => {
if ((error as any).type === 'NotFound') return error as DatabaseError
return { type: 'QueryError', message: String(error) }
}
),
findByCustomer: (customerId) =>
TE.tryCatch(
async () => {
const result = await pool.query(
'SELECT * FROM orders WHERE customer_id = $1',
[customerId]
)
return result.rows.map(mapRowToOrder)
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
save: (order) =>
TE.tryCatch(
async () => {
await pool.query(
`INSERT INTO orders (id, customer_id, items, status, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
items = $3, status = $4`,
[order.id, order.customerId, JSON.stringify(order.items),
order.status, order.createdAt]
)
return order
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
updateStatus: (id, status) =>
TE.tryCatch(
async () => {
await pool.query(
'UPDATE orders SET status = $1 WHERE id = $2',
[status, id]
)
},
(error): DatabaseError => ({
type: 'QueryError',
message: String(error),
})
),
})
Wiring Pure and Impure at Boundaries
import { pipe } from 'fp-ts/function'
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
import * as Order from '../core/domain/order'
import { OrderRepository } from '../adapters/database/orderRepo'
import { PaymentGateway } from '../adapters/http/paymentApi'
import { Logger } from '../adapters/logging/logger'
export interface OrderService {
processOrder: (orderId: string) => TE.TaskEither<ProcessError, Order.Order>
}
export type ProcessError =
| { type: 'NotFound'; orderId: string }
| { type: 'InvalidState'; message: string }
| { type: 'PaymentFailed'; reason: string }
| { type: 'DatabaseError'; message: string }
export const createOrderService = (
orderRepo: OrderRepository,
paymentGateway: PaymentGateway,
logger: Logger
): OrderService => ({
processOrder: (orderId) =>
pipe(
TE.fromIO(logger.info(`Processing order ${orderId}`)),
TE.chain(() => pipe(
orderRepo.findById(orderId),
TE.mapLeft((e): ProcessError =>
e.type === 'NotFound'
? { type: 'NotFound', orderId }
: { type: 'DatabaseError', message: e.message }
)
)),
TE.chainEitherK((order) =>
Order.canShip(order)
? E.right(order)
: E.left<ProcessError>({
type: 'InvalidState',
message: `Cannot process order in ${order.status} state`
})
),
TE.map((order) => {
const subtotal = Order.calculateSubtotal(order.items)
const tax = Order.calculateTax(subtotal, 0.08)
const total = Order.calculateTotal(subtotal, tax, 0)
return { order, total }
}),
TE.chain(({ order, total }) =>
pipe(
paymentGateway.charge(order.customerId, total),
TE.mapLeft((e): ProcessError => ({
type: 'PaymentFailed',
reason: e.message
})),
TE.map(() => order)
)
),
TE.map(Order.shipOrder),
TE.chain((order) =>
pipe(
orderRepo.save(order),
TE.mapLeft((e): ProcessError => ({
type: 'DatabaseError',
message: e.message
}))
)
),
TE.chainFirst((order) =>
TE.fromIO(logger.info(`Order ${order.id} shipped successfully`))
)
),
})
6. Making Impure Functions Pure Through Dependency Injection
Instead of calling impure operations directly, accept them as parameters. This makes functions pure and testable.
The Problem with Direct Dependencies
const processUser = async (userId: string): Promise<ProcessResult> => {
const user = await database.findUser(userId)
const now = new Date()
const token = generateToken()
console.log(`Processing user ${userId}`)
const enriched = await enrichmentApi.enrich(user)
return { user: enriched, processedAt: now, token }
}
Solution: Dependency Injection
interface ProcessUserDeps {
findUser: (id: string) => Promise<User>
getCurrentTime: () => Date
generateToken: () => string
log: (message: string) => void
enrichUser: (user: User) => Promise<EnrichedUser>
}
const processUser = (deps: ProcessUserDeps) =>
async (userId: string): Promise<ProcessResult> => {
const user = await deps.findUser(userId)
const now = deps.getCurrentTime()
const token = deps.generateToken()
deps.log(`Processing user ${userId}`)
const enriched = await deps.enrichUser(user)
return { user: enriched, processedAt: now, token }
}
const productionDeps: ProcessUserDeps = {
findUser: database.findUser,
getCurrentTime: () => new Date(),
generateToken: () => crypto.randomUUID(),
log: console.log,
enrichUser: enrichmentApi.enrich,
}
const processUserProd = processUser(productionDeps)
await processUserProd('user-123')
describe('processUser', () => {
it('enriches user and returns result', async () => {
const mockUser: User = { id: '123', name: 'Test' }
const mockEnriched: EnrichedUser = { ...mockUser, score: 100 }
const fixedTime = new Date('2024-01-01')
const testDeps: ProcessUserDeps = {
findUser: async () => mockUser,
getCurrentTime: () => fixedTime,
generateToken: () => 'test-token',
log: () => {},
enrichUser: async () => mockEnriched,
}
const result = await processUser(testDeps)('123')
expect(result).toEqual({
user: mockEnriched,
processedAt: fixedTime,
token: 'test-token',
})
})
})
Reader Pattern for Dependency Injection
import { pipe } from 'fp-ts/function'
import * as R from 'fp-ts/Reader'
import * as RT from 'fp-ts/ReaderTask'
import * as RTE from 'fp-ts/ReaderTaskEither'
interface AppEnv {
userRepo: UserRepository
logger: Logger
config: AppConfig
clock: { now: () => Date }
}
const findUser = (id: string): RTE.ReaderTaskEither<AppEnv, Error, User> =>
(env) => env.userRepo.findById(id)
const logMessage = (msg: string): RT.ReaderTask<AppEnv, void> =>
(env) => async () => env.logger.info(msg)
const getConfig = <K extends keyof AppConfig>(key: K): R.Reader<AppEnv, AppConfig[K]> =>
(env) => env.config[key]
const getCurrentTime: R.Reader<AppEnv, Date> =
(env) => env.clock.now()
const processUserWithReader = (
userId: string
): RTE.ReaderTaskEither<AppEnv, ProcessError, ProcessResult> =>
pipe(
RTE.fromReaderTask(logMessage(`Processing user ${userId}`)),
RTE.chain(() => findUser(userId)),
RTE.bindTo('user'),
RTE.bind('time', () => RTE.fromReader(getCurrentTime)),
RTE.map(({ user, time }) => ({
user,
processedAt: time,
}))
)
const productionEnv: AppEnv = {
userRepo: createUserRepository(databasePool),
logger: createLogger('app'),
config: loadConfig(),
clock: { now: () => new Date() },
}
const result = await processUserWithReader('user-123')(productionEnv)()
const testEnv: AppEnv = {
userRepo: {
findById: () => TE.right(mockUser),
} as UserRepository,
logger: { info: () => {} } as Logger,
config: { apiUrl: 'http://test' } as AppConfig,
clock: { now: () => new Date('2024-01-01') },
}
const testResult = await processUserWithReader('user-123')(testEnv)()
Functional Core with Injected Effects
interface OrderProcessingEffects {
getOrder: (id: string) => TE.TaskEither<Error, Order>
getInventory: (productId: string) => TE.TaskEither<Error, number>
saveOrder: (order: Order) => TE.TaskEither<Error, void>
sendNotification: (email: string, message: string) => TE.TaskEither<Error, void>
getCurrentTime: () => Date
}
const calculateShippingDate = (orderDate: Date, priority: Priority): Date => {
const days = priority === 'express' ? 1 : 5
const shipping = new Date(orderDate)
shipping.setDate(shipping.getDate() + days)
return shipping
}
const canFulfill = (order: Order, inventory: Map<string, number>): boolean =>
order.items.every(item =>
(inventory.get(item.productId) ?? 0) >= item.quantity
)
const processOrderWorkflow = (effects: OrderProcessingEffects) =>
(orderId: string): TE.TaskEither<ProcessError, ProcessedOrder> =>
pipe(
effects.getOrder(orderId),
TE.bindTo('order'),
TE.bind('inventory', ({ order }) =>
pipe(
order.items.map(item =>
pipe(
effects.getInventory(item.productId),
TE.map(qty => [item.productId, qty] as const)
)
),
TE.sequenceArray,
TE.map(entries => new Map(entries))
)
),
TE.chainEitherK(({ order, inventory }) =>
canFulfill(order, inventory)
? E.right({ order, inventory })
: E.left({ type: 'OutOfStock' as const })
),
TE.map(({ order }) => {
const now = effects.getCurrentTime()
const shippingDate = calculateShippingDate(now, order.priority)
return {
...order,
status: 'confirmed' as const,
confirmedAt: now,
estimatedShipping: shippingDate,
}
}),
TE.chainFirst(processedOrder => effects.saveOrder(processedOrder)),
TE.chainFirst(processedOrder =>
effects.sendNotification(
processedOrder.customerEmail,
`Order confirmed! Ships by ${processedOrder.estimatedShipping.toDateString()}`
)
)
)
7. When Side Effects Are Acceptable
Side effects aren't always bad. Here's guidance on when they're acceptable.
Logging and Telemetry
const processPayment = async (payment: Payment): Promise<PaymentResult> => {
logger.info('Processing payment', { paymentId: payment.id })
const startTime = performance.now()
const result = await paymentGateway.process(payment)
metrics.recordTiming('payment.processing', performance.now() - startTime)
metrics.increment('payment.processed', { status: result.status })
span.setTag('payment.status', result.status)
return result
}
Caching (When Semantically Transparent)
const cache = new Map<string, User>()
const getUserCached = async (id: string): Promise<User> => {
const cached = cache.get(id)
if (cached) {
return cached
}
const user = await database.findUser(id)
cache.set(id, user)
return user
}
Configuration Loading at Startup
interface AppConfig {
readonly apiUrl: string
readonly maxRetries: number
readonly featureFlags: readonly string[]
}
const loadConfig = (): AppConfig => ({
apiUrl: process.env.API_URL ?? 'http://localhost:3000',
maxRetries: parseInt(process.env.MAX_RETRIES ?? '3', 10),
featureFlags: (process.env.FEATURE_FLAGS ?? '').split(',').filter(Boolean),
})
export const config: AppConfig = Object.freeze(loadConfig())
Development/Debug Tools
const debugLog = (message: string, data?: unknown): void => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${message}`, data)
}
}
const processData = (input: InputData): OutputData => {
debugLog('Processing input', input)
const result = transformData(input)
debugLog('Transform complete', result)
return result
}
Side Effects to Avoid
let globalDiscount = 0.1
const calculatePrice = (base: number): number => base * (1 - globalDiscount)
const validateUser = (user: User): boolean => {
const rules = getValidationRules()
return rules.every(rule => rule(user))
}
const shouldRetry = (): boolean => Math.random() < 0.5
const processItems = (items: Item[]): void => {
items.forEach(item => {
item.processed = true
})
}
Practical Exercises
Exercise 1: Identify Side Effects
Classify each function as pure or impure. If impure, identify the side effect type.
const formatDate = (date: Date): string =>
date.toISOString().split('T')[0]
const formatCurrentDate = (): string =>
new Date().toISOString().split('T')[0]
const memoize = <A, B>(fn: (a: A) => B): (a: A) => B => {
const cache = new Map<A, B>()
return (a: A) => {
if (cache.has(a)) return cache.get(a)!
const result = fn(a)
cache.set(a, result)
return result
}
}
const sortUsers = (users: User[]): User[] =>
users.sort((a, b) => a.name.localeCompare(b.name))
const getUserAge = (user: User): number => {
const today = new Date()
return today.getFullYear() - user.birthYear
}
Solutions
const formatDate = (date: Date): string =>
date.toISOString().split('T')[0]
const formatCurrentDate = (): string =>
new Date().toISOString().split('T')[0]
const formatCurrentDate = (now: Date): string =>
now.toISOString().split('T')[0]
const memoize = <A, B>(fn: (a: A) => B): (a: A) => B => {
const cache = new Map<A, B>()
return (a: A) => {
if (cache.has(a)) return cache.get(a)!
const result = fn(a)
cache.set(a, result)
return result
}
}
const sortUsers = (users: User[]): User[] =>
users.sort((a, b) => a.name.localeCompare(b.name))
const sortUsers = (users: readonly User[]): User[] =>
[...users].sort((a, b) => a.name.localeCompare(b.name))
const getUserAge = (user: User): number => {
const today = new Date()
return today.getFullYear() - user.birthYear
}
const getUserAge = (user: User, today: Date): number =>
today.getFullYear() - user.birthYear
Exercise 2: Isolate Effects
Refactor this impure function into a pure core with an impure shell.
const processOrder = async (orderId: string): Promise<void> => {
console.log(`Starting order processing: ${orderId}`)
const order = await fetch(`/api/orders/${orderId}`).then(r => r.json())
if (order.items.length === 0) {
throw new Error('Empty order')
}
const subtotal = order.items.reduce(
(sum: number, item: { price: number; quantity: number }) =>
sum + item.price * item.quantity,
0
)
const discount = subtotal > 100 ? 0.1 : 0
const tax = subtotal * 0.08
const total = subtotal * (1 - discount) + tax
const processedOrder = {
...order,
subtotal,
discount,
tax,
total,
processedAt: new Date().toISOString(),
}
await fetch(`/api/orders/${orderId}`, {
method: 'PUT',
body: JSON.stringify(processedOrder),
})
console.log(`Order processed: ${orderId}, total: $${total}`)
}
Solution
interface OrderItem {
price: number
quantity: number
}
interface Order {
id: string
items: OrderItem[]
}
interface ProcessedOrder extends Order {
subtotal: number
discount: number
tax: number
total: number
processedAt: string
}
const validateOrder = (order: Order): Either<string, Order> =>
order.items.length === 0
? E.left('Empty order')
: E.right(order)
const calculateSubtotal = (items: readonly OrderItem[]): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const calculateDiscount = (subtotal: number): number =>
subtotal > 100 ? 0.1 : 0
const calculateTax = (subtotal: number): number =>
subtotal * 0.08
const calculateTotal = (subtotal: number, discount: number, tax: number): number =>
subtotal * (1 - discount) + tax
const processOrderData = (order: Order, timestamp: string): ProcessedOrder => {
const subtotal = calculateSubtotal(order.items)
const discount = calculateDiscount(subtotal)
const tax = calculateTax(subtotal)
const total = calculateTotal(subtotal, discount, tax)
return {
...order,
subtotal,
discount,
tax,
total,
processedAt: timestamp,
}
}
interface OrderEffects {
fetchOrder: (id: string) => TE.TaskEither<Error, Order>
saveOrder: (order: ProcessedOrder) => TE.TaskEither<Error, void>
log: (message: string) => void
getCurrentTime: () => Date
}
const processOrder = (effects: OrderEffects) =>
(orderId: string): TE.TaskEither<Error, ProcessedOrder> =>
pipe(
TE.fromIO(() => effects.log(`Starting order processing: ${orderId}`)),
TE.chain(() => effects.fetchOrder(orderId)),
TE.chainEitherK(order =>
pipe(
validateOrder(order),
E.mapLeft(msg => new Error(msg))
)
),
TE.map(order => {
const timestamp = effects.getCurrentTime().toISOString()
return processOrderData(order, timestamp)
}),
TE.chainFirst(processedOrder => effects.saveOrder(processedOrder)),
TE.chainFirst(processedOrder =>
TE.fromIO(() =>
effects.log(`Order processed: ${orderId}, total: $${processedOrder.total}`)
)
)
)
const productionEffects: OrderEffects = {
fetchOrder: (id) =>
TE.tryCatch(
() => fetch(`/api/orders/${id}`).then(r => r.json()),
(e) => new Error(String(e))
),
saveOrder: (order) =>
TE.tryCatch(
() => fetch(`/api/orders/${order.id}`, {
method: 'PUT',
body: JSON.stringify(order),
}).then(() => undefined),
(e) => new Error(String(e))
),
log: console.log,
getCurrentTime: () => new Date(),
}
const processOrderProd = processOrder(productionEffects)
describe('processOrderData', () => {
it('applies 10% discount for orders over $100', () => {
const order: Order = {
id: '123',
items: [{ price: 50, quantity: 3 }],
}
const result = processOrderData(order, '2024-01-01T00:00:00Z')
expect(result.subtotal).toBe(150)
expect(result.discount).toBe(0.1)
expect(result.tax).toBe(12)
expect(result.total).toBe(147)
})
})
Exercise 3: Make It Idempotent
This payment processor has race condition issues. Make it idempotent.
const processPayment = async (
userId: string,
amount: number
): Promise<PaymentResult> => {
const user = await database.getUser(userId)
if (user.balance < amount) {
return { success: false, reason: 'Insufficient funds' }
}
await database.updateUser(userId, {
balance: user.balance - amount,
})
const transactionId = generateId()
await database.insertTransaction({
id: transactionId,
userId,
amount,
timestamp: new Date(),
})
return { success: true, transactionId }
}
Solution
interface PaymentRequest {
idempotencyKey: string
userId: string
amount: number
}
interface PaymentResult {
success: boolean
transactionId?: string
reason?: string
}
const processPayment = async (
request: PaymentRequest
): Promise<PaymentResult> => {
const existing = await database.findTransactionByIdempotencyKey(
request.idempotencyKey
)
if (existing) {
return {
success: true,
transactionId: existing.id,
}
}
return await database.transaction(async (tx) => {
const user = await tx.getUserForUpdate(request.userId)
if (user.balance < request.amount) {
return { success: false, reason: 'Insufficient funds' }
}
const transactionId = `txn-${request.idempotencyKey}`
await tx.updateUser(request.userId, {
balance: user.balance - request.amount,
})
await tx.insertTransaction({
id: transactionId,
idempotencyKey: request.idempotencyKey,
userId: request.userId,
amount: request.amount,
timestamp: new Date(),
})
return { success: true, transactionId }
})
}
const payForOrder = async (order: Order): Promise<PaymentResult> => {
const request: PaymentRequest = {
idempotencyKey: `payment-${order.id}-${order.total}`,
userId: order.userId,
amount: order.total,
}
return await retryWithBackoff(() => processPayment(request), {
maxAttempts: 3,
backoffMs: 1000,
})
}
Exercise 4: Use IO Type
Convert these functions to use the IO type from fp-ts.
const logMessage = (msg: string): void => {
console.log(msg)
}
const getRandomInt = (max: number): number => {
return Math.floor(Math.random() * max)
}
const getCurrentTimestamp = (): string => {
return new Date().toISOString()
}
Solution
import { pipe } from 'fp-ts/function'
import * as IO from 'fp-ts/IO'
const logMessage = (msg: string): IO.IO<void> =>
() => console.log(msg)
const getRandomInt = (max: number): IO.IO<number> =>
() => Math.floor(Math.random() * max)
const getCurrentTimestamp: IO.IO<string> =
() => new Date().toISOString()
const logRandomNumber: IO.IO<void> = pipe(
getCurrentTimestamp,
IO.bindTo('timestamp'),
IO.bind('randomNum', () => getRandomInt(100)),
IO.chain(({ timestamp, randomNum }) =>
logMessage(`[${timestamp}] Random number: ${randomNum}`)
)
)
const logRandomNumber2: IO.IO<void> = pipe(
IO.Do,
IO.bind('timestamp', () => getCurrentTimestamp),
IO.bind('randomNum', () => getRandomInt(100)),
IO.chain(({ timestamp, randomNum }) =>
logMessage(`[${timestamp}] Random number: ${randomNum}`)
)
)
logRandomNumber()
logRandomNumber()
logRandomNumber()
Summary
| Concept | Key Idea | Benefit |
|---|
| Side Effects | Inputs/outputs beyond parameters and return value | Understanding what makes code impure |
| Pure Core, Impure Shell | Business logic pure, effects at boundaries | Testable, predictable core |
| Idempotence | Same operation multiple times = same result | Safe retries, async safety |
| IO Type | Deferred synchronous effects as values | Composable effect descriptions |
| Quarantine | Isolate impure code in specific modules | Clear separation of concerns |
| Dependency Injection | Accept effects as parameters | Testable, configurable functions |
Key Takeaways
- Side effects are unavoidable - the goal is control, not elimination
- Push effects to the edges - pure core, thin impure shell
- Make dependencies explicit - inject effects, don't hide them
- Design for idempotence - especially for async operations
- Use types to track effects - IO, Task, TaskEither make effects visible
- Accept controlled impurity - logging, caching, config loading are often acceptable
Next Steps
With side effect management understood, you're ready for:
- fp-ts Task and TaskEither: Async effect handling
- fp-ts Reader: Dependency injection via environment
- Effect systems: More advanced effect tracking (Effect-TS)
- Testing strategies: Property-based testing for pure functions
Remember: The functional approach to side effects isn't about purity for its own sake - it's about making your code more predictable, testable, and maintainable. Start with the "pure core, impure shell" pattern and gradually adopt more sophisticated effect handling as needed.