fp-ts Backend Patterns
Functional programming patterns for building type-safe, testable backend services using fp-ts.
Core Concepts
ReaderTaskEither (RTE)
The ReaderTaskEither<R, E, A> type is the backbone of functional backend development:
-
R (Reader): Dependencies/environment (database, config, logger)
-
E (Either left): Error type
-
A (Either right): Success value
import * as RTE from 'fp-ts/ReaderTaskEither' import * as TE from 'fp-ts/TaskEither' import { pipe } from 'fp-ts/function'
// Define your dependencies type Deps = { db: DatabaseClient logger: Logger config: Config }
// Define domain errors type AppError = | { _tag: 'NotFound'; resource: string; id: string } | { _tag: 'ValidationError'; message: string } | { _tag: 'DatabaseError'; cause: unknown } | { _tag: 'Unauthorized'; reason: string }
// A service function
const getUser = (id: string): RTE.ReaderTaskEither<Deps, AppError, User> =>
pipe(
RTE.ask<Deps>(),
RTE.flatMap(({ db, logger }) =>
pipe(
RTE.fromTaskEither(db.users.findById(id)),
RTE.mapLeft((e): AppError => ({ _tag: 'DatabaseError', cause: e })),
RTE.flatMap(user =>
user
? RTE.right(user)
: RTE.left({ _tag: 'NotFound', resource: 'User', id })
),
RTE.tap(user => RTE.fromIO(() => logger.info(Found user: ${user.id})))
)
)
)
Service Layer Patterns
Defining Service Modules
Structure services as modules exporting RTE functions:
// src/services/user.service.ts import * as RTE from 'fp-ts/ReaderTaskEither' import * as TE from 'fp-ts/TaskEither' import * as A from 'fp-ts/Array' import { pipe } from 'fp-ts/function'
type UserDeps = { db: DatabaseClient hasher: PasswordHasher mailer: EmailService }
type UserError = | { _tag: 'UserNotFound'; id: string } | { _tag: 'EmailExists'; email: string } | { _tag: 'InvalidPassword' }
// Create user export const create = ( input: CreateUserInput ): RTE.ReaderTaskEither<UserDeps, UserError, User> => pipe( RTE.ask<UserDeps>(), RTE.flatMap(({ db, hasher }) => pipe( // Check email uniqueness checkEmailUnique(input.email), RTE.flatMap(() => RTE.fromTaskEither(hasher.hash(input.password)) ), RTE.flatMap(hashedPassword => RTE.fromTaskEither( db.users.create({ ...input, password: hashedPassword, }) ) ) ) ) )
// Find by ID export const findById = ( id: string ): RTE.ReaderTaskEither<UserDeps, UserError, User> => pipe( RTE.ask<UserDeps>(), RTE.flatMap(({ db }) => pipe( RTE.fromTaskEither(db.users.findUnique({ where: { id } })), RTE.flatMap(user => user ? RTE.right(user) : RTE.left({ _tag: 'UserNotFound' as const, id }) ) ) ) )
// Find many with pagination export const findMany = ( params: PaginationParams ): RTE.ReaderTaskEither<UserDeps, UserError, PaginatedResult<User>> => pipe( RTE.ask<UserDeps>(), RTE.flatMap(({ db }) => RTE.fromTaskEither( pipe( TE.Do, TE.bind('users', () => db.users.findMany({ skip: params.offset, take: params.limit, })), TE.bind('total', () => db.users.count()), TE.map(({ users, total }) => ({ data: users, total, ...params, })) ) ) ) )
const checkEmailUnique = ( email: string ): RTE.ReaderTaskEither<UserDeps, UserError, void> => pipe( RTE.ask<UserDeps>(), RTE.flatMap(({ db }) => pipe( RTE.fromTaskEither(db.users.findUnique({ where: { email } })), RTE.flatMap(existing => existing ? RTE.left({ _tag: 'EmailExists' as const, email }) : RTE.right(undefined) ) ) ) )
Composing Services
// src/services/order.service.ts import * as UserService from './user.service' import * as ProductService from './product.service' import * as PaymentService from './payment.service'
type OrderDeps = UserService.UserDeps & ProductService.ProductDeps & PaymentService.PaymentDeps & { db: DatabaseClient }
export const createOrder = ( userId: string, items: OrderItem[] ): RTE.ReaderTaskEither<OrderDeps, OrderError, Order> => pipe( RTE.Do, // Validate user exists RTE.bind('user', () => pipe( UserService.findById(userId), RTE.mapLeft(toOrderError) ) ), // Validate and get products RTE.bind('products', () => pipe( items, A.traverse(RTE.ApplicativePar)(item => ProductService.findById(item.productId) ), RTE.mapLeft(toOrderError) ) ), // Calculate total RTE.bind('total', ({ products }) => RTE.right(calculateTotal(products, items)) ), // Process payment RTE.bind('payment', ({ user, total }) => pipe( PaymentService.charge(user, total), RTE.mapLeft(toOrderError) ) ), // Create order RTE.flatMap(({ user, products, total, payment }) => createOrderRecord(user, products, items, total, payment) ) )
Functional Dependency Injection
Building the Dependency Container
// src/deps.ts import { pipe } from 'fp-ts/function' import * as TE from 'fp-ts/TaskEither' import * as RTE from 'fp-ts/ReaderTaskEither'
// Layer 0: Config (no dependencies) type Config = { database: { url: string; poolSize: number } redis: { url: string } jwt: { secret: string; expiresIn: string } }
const loadConfig = (): TE.TaskEither<Error, Config> =>
TE.tryCatch(
async () => ({
database: {
url: process.env.DATABASE_URL!,
poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
},
redis: { url: process.env.REDIS_URL! },
jwt: {
secret: process.env.JWT_SECRET!,
expiresIn: process.env.JWT_EXPIRES || '1d',
},
}),
(e) => new Error(Config error: ${e})
)
// Layer 1: Infrastructure (depends on config) type Infrastructure = { config: Config db: PrismaClient redis: RedisClient logger: Logger }
const buildInfrastructure = (
config: Config
): TE.TaskEither<Error, Infrastructure> =>
pipe(
TE.Do,
TE.bind('db', () =>
TE.tryCatch(
async () => {
const prisma = new PrismaClient({
datasources: { db: { url: config.database.url } },
})
await prisma.$connect()
return prisma
},
(e) => new Error(Database error: ${e})
)
),
TE.bind('redis', () =>
TE.tryCatch(
async () => createRedisClient(config.redis.url),
(e) => new Error(Redis error: ${e})
)
),
TE.bind('logger', () => TE.right(createLogger())),
TE.map(({ db, redis, logger }) => ({
config,
db,
redis,
logger,
}))
)
// Layer 2: Services (depends on infrastructure) type Services = { hasher: PasswordHasher jwt: JwtService mailer: EmailService }
const buildServices = (infra: Infrastructure): Services => ({ hasher: createBcryptHasher(), jwt: createJwtService(infra.config.jwt), mailer: createEmailService(infra.config), })
// Full application dependencies export type AppDeps = Infrastructure & Services
export const buildDeps = (): TE.TaskEither<Error, AppDeps> => pipe( loadConfig(), TE.flatMap(buildInfrastructure), TE.map(infra => ({ ...infra, ...buildServices(infra), })) )
// Cleanup
export const destroyDeps = (deps: AppDeps): TE.TaskEither<Error, void> =>
pipe(
TE.tryCatch(
async () => {
await deps.db.$disconnect()
await deps.redis.quit()
},
(e) => new Error(Cleanup error: ${e})
)
)
Running Programs with Dependencies
// src/main.ts import { pipe } from 'fp-ts/function' import * as TE from 'fp-ts/TaskEither' import * as RTE from 'fp-ts/ReaderTaskEither'
const program: RTE.ReaderTaskEither<AppDeps, AppError, void> = pipe( RTE.ask<AppDeps>(), RTE.flatMap(deps => pipe( startServer(deps), RTE.fromTaskEither ) ) )
const main = async () => { const result = await pipe( buildDeps(), TE.mapLeft((e): AppError => ({ _tag: 'StartupError', cause: e })), TE.flatMap(deps => pipe( program(deps), TE.tap(() => TE.fromIO(() => console.log('Server running'))), // Cleanup on exit TE.tapError(() => destroyDeps(deps)) ) ) )()
if (result._tag === 'Left') { console.error('Failed to start:', result.left) process.exit(1) } }
main()
Database Operations
Prisma Wrappers
// src/lib/db.ts import * as TE from 'fp-ts/TaskEither' import * as O from 'fp-ts/Option' import { PrismaClient, Prisma } from '@prisma/client'
type DbError = | { _tag: 'RecordNotFound'; model: string; id: string } | { _tag: 'UniqueViolation'; field: string } | { _tag: 'ForeignKeyViolation'; field: string } | { _tag: 'UnknownDbError'; cause: unknown }
// Wrap Prisma operations const wrapPrisma = <A>( operation: () => Promise<A> ): TE.TaskEither<DbError, A> => TE.tryCatch(operation, (error): DbError => { if (error instanceof Prisma.PrismaClientKnownRequestError) { switch (error.code) { case 'P2002': return { _tag: 'UniqueViolation', field: (error.meta?.target as string[])?.join(', ') || 'unknown', } case 'P2003': return { _tag: 'ForeignKeyViolation', field: error.meta?.field_name as string || 'unknown', } case 'P2025': return { _tag: 'RecordNotFound', model: error.meta?.modelName as string || 'unknown', id: 'unknown', } } } return { _tag: 'UnknownDbError', cause: error } })
// Repository factory export const createRepository = < Model, CreateInput, UpdateInput, WhereUnique, WhereMany
( db: PrismaClient, delegate: { findUnique: (args: { where: WhereUnique }) => Promise<Model | null> findMany: (args: { where?: WhereMany; skip?: number; take?: number }) => Promise<Model[]> create: (args: { data: CreateInput }) => Promise<Model> update: (args: { where: WhereUnique; data: UpdateInput }) => Promise<Model> delete: (args: { where: WhereUnique }) => Promise<Model> count: (args?: { where?: WhereMany }) => Promise<number> } ) => ({ findUnique: (where: WhereUnique): TE.TaskEither<DbError, O.Option<Model>> => pipe( wrapPrisma(() => delegate.findUnique({ where })), TE.map(O.fromNullable) ),
findMany: ( where?: WhereMany, pagination?: { skip: number; take: number } ): TE.TaskEither<DbError, Model[]> => wrapPrisma(() => delegate.findMany({ where, ...pagination })),
create: (data: CreateInput): TE.TaskEither<DbError, Model> => wrapPrisma(() => delegate.create({ data })),
update: ( where: WhereUnique, data: UpdateInput ): TE.TaskEither<DbError, Model> => wrapPrisma(() => delegate.update({ where, data })),
delete: (where: WhereUnique): TE.TaskEither<DbError, Model> => wrapPrisma(() => delegate.delete({ where })),
count: (where?: WhereMany): TE.TaskEither<DbError, number> => wrapPrisma(() => delegate.count({ where })), })
// Usage const userRepo = createRepository(prisma, prisma.user)
Transaction Handling
// src/lib/transaction.ts import * as TE from 'fp-ts/TaskEither' import * as RTE from 'fp-ts/ReaderTaskEither' import { PrismaClient } from '@prisma/client' import { pipe } from 'fp-ts/function'
type TxClient = Omit< PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use'
type TxDeps = { tx: TxClient }
// Transaction wrapper export const withTransaction = <R extends { db: PrismaClient }, E, A>( program: RTE.ReaderTaskEither<R & TxDeps, E, A> ): RTE.ReaderTaskEither<R, E | DbError, A> => pipe( RTE.ask<R>(), RTE.flatMap(deps => RTE.fromTaskEither( TE.tryCatch( () => deps.db.$transaction(async tx => { const result = await program({ ...deps, tx })() if (result._tag === 'Left') { throw result.left // Rollback } return result.right }), (error): E | DbError => { // Re-throw domain errors if (typeof error === 'object' && error !== null && '_tag' in error) { return error as E } return { _tag: 'UnknownDbError', cause: error } } ) ) ) )
// Usage in service export const transferFunds = ( fromId: string, toId: string, amount: number ): RTE.ReaderTaskEither<AppDeps, TransferError, Transfer> => withTransaction( pipe( RTE.Do, RTE.bind('from', () => debitAccount(fromId, amount)), RTE.bind('to', () => creditAccount(toId, amount)), RTE.bind('transfer', ({ from, to }) => createTransferRecord(from, to, amount) ), RTE.map(({ transfer }) => transfer) ) )
// Inside transaction, use tx instead of db const debitAccount = ( accountId: string, amount: number ): RTE.ReaderTaskEither<TxDeps, TransferError, Account> => pipe( RTE.ask<TxDeps>(), RTE.flatMap(({ tx }) => RTE.fromTaskEither( pipe( TE.tryCatch( () => tx.account.update({ where: { id: accountId }, data: { balance: { decrement: amount } }, }), toDbError ), TE.flatMap(account => account.balance < 0 ? TE.left({ _tag: 'InsufficientFunds' as const, accountId }) : TE.right(account) ) ) ) ) )
Middleware Patterns
Express Middleware
// src/middleware/fp-express.ts import { Request, Response, NextFunction, RequestHandler } from 'express' import * as TE from 'fp-ts/TaskEither' import * as RTE from 'fp-ts/ReaderTaskEither' import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function'
// Convert RTE handler to Express middleware export const toHandler = <R, E, A>( getDeps: (req: Request) => R, handler: (req: Request) => RTE.ReaderTaskEither<R, E, A>, onError: (error: E, res: Response) => void ): RequestHandler => async (req, res, next) => { const deps = getDeps(req) const result = await handler(req)(deps)()
pipe(
result,
E.fold(
error => onError(error, res),
data => res.json(data)
)
)
}
// Error handler const handleError = (error: AppError, res: Response): void => { switch (error._tag) { case 'NotFound': res.status(404).json({ error: error.resource + ' not found' }) break case 'ValidationError': res.status(400).json({ error: error.message }) break case 'Unauthorized': res.status(401).json({ error: error.reason }) break default: res.status(500).json({ error: 'Internal server error' }) } }
// Usage const getUserHandler = toHandler( req => req.app.locals.deps as AppDeps, req => UserService.findById(req.params.id), handleError )
app.get('/users/:id', getUserHandler)
Hono Middleware
// src/middleware/fp-hono.ts import { Hono, Context, MiddlewareHandler } from 'hono' import * as RTE from 'fp-ts/ReaderTaskEither' import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function'
// Store deps in context declare module 'hono' { interface ContextVariableMap { deps: AppDeps } }
// Dependency injection middleware export const withDeps = (deps: AppDeps): MiddlewareHandler => async (c, next) => { c.set('deps', deps) await next() }
// Convert RTE to Hono handler export const toHonoHandler = <E, A>( handler: (c: Context) => RTE.ReaderTaskEither<AppDeps, E, A>, onError: (error: E, c: Context) => Response ) => async (c: Context): Promise<Response> => { const deps = c.get('deps') const result = await handler(c)(deps)()
return pipe(
result,
E.fold(
error => onError(error, c),
data => c.json(data)
)
)
}
// Validation middleware export const validate = <T>(schema: z.ZodSchema<T>): MiddlewareHandler => async (c, next) => { const body = await c.req.json() const result = schema.safeParse(body)
if (!result.success) {
return c.json(
{ error: 'Validation failed', details: result.error.flatten() },
400
)
}
c.set('validatedBody', result.data)
await next()
}
// Auth middleware using RTE export const requireAuth: MiddlewareHandler = async (c, next) => { const deps = c.get('deps') const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) { return c.json({ error: 'No token provided' }, 401) }
const result = await pipe( deps.jwt.verify(token), TE.mapLeft(() => ({ _tag: 'Unauthorized' as const, reason: 'Invalid token' })) )()
if (E.isLeft(result)) { return c.json({ error: result.left.reason }, 401) }
c.set('user', result.right) await next() }
// Usage const app = new Hono()
app.use('', withDeps(deps)) app.use('/api/', requireAuth)
app.get( '/api/users/:id', toHonoHandler( c => UserService.findById(c.req.param('id')), (error, c) => { if (error._tag === 'UserNotFound') { return c.json({ error: 'User not found' }, 404) } return c.json({ error: 'Internal error' }, 500) } ) )
Request Context Pattern
// src/context.ts import * as RTE from 'fp-ts/ReaderTaskEither' import { pipe } from 'fp-ts/function'
// Request-scoped context type RequestContext = { requestId: string userId: O.Option<string> startTime: number }
type ContextDeps = AppDeps & { ctx: RequestContext }
// Logging with context const logWithContext = (level: 'info' | 'warn' | 'error') => (message: string, meta?: object): RTE.ReaderTaskEither<ContextDeps, never, void> => pipe( RTE.ask<ContextDeps>(), RTE.flatMap(({ logger, ctx }) => RTE.fromIO(() => logger[level](message, { ...meta, requestId: ctx.requestId, userId: O.toUndefined(ctx.userId), elapsed: Date.now() - ctx.startTime, }) ) ) )
export const log = { info: logWithContext('info'), warn: logWithContext('warn'), error: logWithContext('error'), }
// Middleware to create context export const withContext: MiddlewareHandler = async (c, next) => { const deps = c.get('deps') const ctx: RequestContext = { requestId: crypto.randomUUID(), userId: O.fromNullable(c.get('user')?.id), startTime: Date.now(), }
c.set('deps', { ...deps, ctx })
// Log request start deps.logger.info('Request started', { requestId: ctx.requestId, method: c.req.method, path: c.req.path, })
await next()
// Log request end deps.logger.info('Request completed', { requestId: ctx.requestId, status: c.res.status, elapsed: Date.now() - ctx.startTime, }) }
Error Handling Patterns
Typed Error Hierarchy
// src/errors.ts import * as E from 'fp-ts/Either' import * as O from 'fp-ts/Option'
// Base error types type DomainError = | NotFoundError | ValidationError | ConflictError | AuthError | InfrastructureError
type NotFoundError = { _tag: 'NotFoundError' resource: string id: string }
type ValidationError = { _tag: 'ValidationError' field: string message: string value?: unknown }
type ConflictError = { _tag: 'ConflictError' resource: string field: string value: string }
type AuthError = | { _tag: 'Unauthenticated' } | { _tag: 'Unauthorized'; required: string } | { _tag: 'TokenExpired' }
type InfrastructureError = { _tag: 'InfrastructureError' service: string cause: unknown }
// Smart constructors export const notFound = (resource: string, id: string): NotFoundError => ({ _tag: 'NotFoundError', resource, id, })
export const validation = ( field: string, message: string, value?: unknown ): ValidationError => ({ _tag: 'ValidationError', field, message, value, })
export const conflict = ( resource: string, field: string, value: string ): ConflictError => ({ _tag: 'ConflictError', resource, field, value, })
// Error to HTTP status mapping export const toHttpStatus = (error: DomainError): number => { switch (error._tag) { case 'NotFoundError': return 404 case 'ValidationError': return 400 case 'ConflictError': return 409 case 'Unauthenticated': return 401 case 'Unauthorized': return 403 case 'TokenExpired': return 401 case 'InfrastructureError': return 503 default: return 500 } }
// Error to response body
export const toResponseBody = (
error: DomainError
): { error: string; details?: unknown } => {
switch (error._tag) {
case 'NotFoundError':
return { error: ${error.resource} not found }
case 'ValidationError':
return {
error: 'Validation failed',
details: { field: error.field, message: error.message },
}
case 'ConflictError':
return {
error: ${error.resource} with ${error.field} already exists,
}
case 'Unauthenticated':
return { error: 'Authentication required' }
case 'Unauthorized':
return { error: Permission denied: ${error.required} }
case 'TokenExpired':
return { error: 'Token expired' }
case 'InfrastructureError':
return { error: 'Service temporarily unavailable' }
}
}
Error Recovery
// src/lib/recovery.ts import * as RTE from 'fp-ts/ReaderTaskEither' import * as TE from 'fp-ts/TaskEither' import { pipe } from 'fp-ts/function'
// Retry with exponential backoff export const withRetry = <R, E, A>( maxAttempts: number, baseDelayMs: number, shouldRetry: (error: E) => boolean ) => ( operation: RTE.ReaderTaskEither<R, E, A> ): RTE.ReaderTaskEither<R, E, A> => pipe( RTE.ask<R>(), RTE.flatMap(deps => { const attempt = ( remaining: number, delay: number ): TE.TaskEither<E, A> => pipe( operation(deps), TE.orElse(error => { if (remaining <= 0 || !shouldRetry(error)) { return TE.left(error) } return pipe( TE.fromTask(() => new Promise(r => setTimeout(r, delay))), TE.flatMap(() => attempt(remaining - 1, delay * 2)) ) }) )
return RTE.fromTaskEither(attempt(maxAttempts - 1, baseDelayMs))
})
)
// Fallback to cached value export const withFallback = <R extends { cache: CacheClient }, E, A>( cacheKey: string, ttlSeconds: number ) => ( operation: RTE.ReaderTaskEither<R, E, A> ): RTE.ReaderTaskEither<R, E, A> => pipe( RTE.ask<R>(), RTE.flatMap(({ cache, ...rest }) => pipe( operation, // On success, cache the result RTE.tap(result => RTE.fromTaskEither(cache.set(cacheKey, result, ttlSeconds)) ), // On failure, try to get cached value RTE.orElse(error => pipe( RTE.fromTaskEither(cache.get<A>(cacheKey)), RTE.flatMap(cached => cached ? RTE.right(cached) : RTE.left(error) ) ) ) ) ) )
// Circuit breaker type CircuitState = 'closed' | 'open' | 'half-open'
export const createCircuitBreaker = <E>( failureThreshold: number, resetTimeoutMs: number, isFailure: (error: E) => boolean ) => { let state: CircuitState = 'closed' let failures = 0 let lastFailure = 0
return <R, A>( operation: RTE.ReaderTaskEither<R, E, A> ): RTE.ReaderTaskEither<R, E | { _tag: 'CircuitOpen' }, A> => pipe( RTE.ask<R>(), RTE.flatMap(deps => { // Check if circuit should reset if ( state === 'open' && Date.now() - lastFailure > resetTimeoutMs ) { state = 'half-open' }
if (state === 'open') {
return RTE.left({ _tag: 'CircuitOpen' as const })
}
return pipe(
operation,
RTE.tap(() => {
if (state === 'half-open') {
state = 'closed'
failures = 0
}
return RTE.right(undefined)
}),
RTE.tapError(error => {
if (isFailure(error)) {
failures++
lastFailure = Date.now()
if (failures >= failureThreshold) {
state = 'open'
}
}
return RTE.right(undefined)
})
)
})
)
}
Testing Strategies
Mocking Dependencies
// src/services/tests/user.service.test.ts import * as TE from 'fp-ts/TaskEither' import * as E from 'fp-ts/Either' import * as O from 'fp-ts/Option' import { describe, it, expect, vi } from 'vitest' import * as UserService from '../user.service'
// Create mock dependencies
const createMockDeps = (overrides: Partial<UserDeps> = {}): UserDeps => ({
db: {
users: {
findUnique: vi.fn(() => Promise.resolve(null)),
create: vi.fn(data => Promise.resolve({ id: '1', ...data })),
update: vi.fn((where, data) => Promise.resolve({ id: where.id, ...data })),
},
},
hasher: {
hash: vi.fn(password => TE.right(hashed_${password})),
verify: vi.fn(() => TE.right(true)),
},
mailer: {
send: vi.fn(() => TE.right(undefined)),
},
...overrides,
})
describe('UserService', () => { describe('create', () => { it('should create a user with hashed password', async () => { const deps = createMockDeps() const input = { email: 'test@example.com', password: 'secret123', name: 'Test User', }
const result = await UserService.create(input)(deps)()
expect(E.isRight(result)).toBe(true)
if (E.isRight(result)) {
expect(result.right.email).toBe(input.email)
}
expect(deps.hasher.hash).toHaveBeenCalledWith('secret123')
})
it('should fail when email already exists', async () => {
const existingUser = { id: '1', email: 'test@example.com' }
const deps = createMockDeps({
db: {
users: {
findUnique: vi.fn(() => Promise.resolve(existingUser)),
create: vi.fn(),
},
},
})
const result = await UserService.create({
email: 'test@example.com',
password: 'secret',
name: 'Test',
})(deps)()
expect(E.isLeft(result)).toBe(true)
if (E.isLeft(result)) {
expect(result.left._tag).toBe('EmailExists')
}
})
})
describe('findById', () => { it('should return user when found', async () => { const user = { id: '1', email: 'test@example.com', name: 'Test' } const deps = createMockDeps({ db: { users: { findUnique: vi.fn(() => Promise.resolve(user)), }, }, })
const result = await UserService.findById('1')(deps)()
expect(E.isRight(result)).toBe(true)
if (E.isRight(result)) {
expect(result.right).toEqual(user)
}
})
it('should return NotFound when user does not exist', async () => {
const deps = createMockDeps()
const result = await UserService.findById('nonexistent')(deps)()
expect(E.isLeft(result)).toBe(true)
if (E.isLeft(result)) {
expect(result.left._tag).toBe('UserNotFound')
expect(result.left.id).toBe('nonexistent')
}
})
}) })
Integration Testing with Test Containers
// src/tests/integration/user.integration.test.ts import { PostgreSqlContainer } from '@testcontainers/postgresql' import { PrismaClient } from '@prisma/client' import * as TE from 'fp-ts/TaskEither' import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { buildDeps, destroyDeps, AppDeps } from '../../deps' import * as UserService from '../../services/user.service'
describe('UserService Integration', () => { let container: PostgreSqlContainer let deps: AppDeps
beforeAll(async () => { // Start PostgreSQL container container = await new PostgreSqlContainer().start()
// Build real dependencies with test database
process.env.DATABASE_URL = container.getConnectionUri()
const depsResult = await buildDeps()()
if (E.isLeft(depsResult)) {
throw new Error(`Failed to build deps: ${depsResult.left}`)
}
deps = depsResult.right
// Run migrations
await deps.db.$executeRaw`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`
// ... run Prisma migrations
}, 60000)
afterAll(async () => { await destroyDeps(deps)() await container.stop() })
it('should create and retrieve a user', async () => { // Create user const createResult = await UserService.create({ email: 'integration@test.com', password: 'password123', name: 'Integration Test', })(deps)()
expect(E.isRight(createResult)).toBe(true)
if (E.isLeft(createResult)) return
const user = createResult.right
// Retrieve user
const findResult = await UserService.findById(user.id)(deps)()
expect(E.isRight(findResult)).toBe(true)
if (E.isRight(findResult)) {
expect(findResult.right.email).toBe('integration@test.com')
}
}) })
Property-Based Testing
// src/tests/property/user.property.test.ts import * as fc from 'fast-check' import * as E from 'fp-ts/Either' import { describe, it, expect } from 'vitest' import { validateEmail, validatePassword } from '../../validation'
describe('Validation Properties', () => { it('valid emails should pass validation', () => { fc.assert( fc.property(fc.emailAddress(), email => { const result = validateEmail(email) return E.isRight(result) }) ) })
it('passwords meeting requirements should pass', () => { const validPassword = fc .tuple( fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'), { minLength: 4, }), fc.stringOf(fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), { minLength: 1, }), fc.stringOf(fc.constantFrom(...'0123456789'), { minLength: 1 }), fc.stringOf(fc.constantFrom(...'!@#$%^&*'), { minLength: 1 }) ) .map(parts => parts.join(''))
fc.assert(
fc.property(validPassword, password => {
const result = validatePassword(password)
return E.isRight(result)
})
)
})
it('empty strings should fail email validation', () => { const result = validateEmail('') expect(E.isLeft(result)).toBe(true) }) })
Quick Reference
Common Imports
import * as RTE from 'fp-ts/ReaderTaskEither' import * as TE from 'fp-ts/TaskEither' import * as E from 'fp-ts/Either' import * as O from 'fp-ts/Option' import * as A from 'fp-ts/Array' import * as T from 'fp-ts/Task' import { pipe, flow } from 'fp-ts/function'
RTE Cheat Sheet
Operation Description
RTE.right(a)
Lift value into success
RTE.left(e)
Create error
RTE.ask<R>()
Get dependencies
RTE.fromTaskEither(te)
Lift TaskEither
RTE.fromEither(e)
Lift Either
RTE.fromOption(onNone)(o)
Lift Option
RTE.flatMap(f)
Chain operations
RTE.map(f)
Transform success
RTE.mapLeft(f)
Transform error
RTE.tap(f)
Side effect on success
RTE.tapError(f)
Side effect on error
RTE.orElse(f)
Recover from error
RTE.getOrElse(f)
Extract with fallback
Service Template
// Template for a new service import * as RTE from 'fp-ts/ReaderTaskEither' import { pipe } from 'fp-ts/function'
type MyServiceDeps = { db: DatabaseClient // ... other dependencies }
type MyServiceError = | { _tag: 'NotFound'; id: string } | { _tag: 'ValidationFailed'; reason: string }
export const myOperation = ( input: Input ): RTE.ReaderTaskEither<MyServiceDeps, MyServiceError, Output> => pipe( RTE.ask<MyServiceDeps>(), RTE.flatMap(deps => // Your implementation here RTE.right(output) ) )