Preparing Archive
fp-backend
Functional programming patterns for Node.js/Deno backend development using fp-ts, ReaderTaskEither, and functional dependency injection
Architectural Overview
"This module is grounded in ai engineering patterns and exposes 1 core capabilities across 1 execution phases."
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(() =>
loggerlevel,
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)
)
)
Primary Stack
TypeScript
Tooling Surface
Guide only
Workspace Path
.agents/skills/fp-backend
Operational Ecosystem
The complete hardware and software toolchain required.
Module Topology
Antigravity Core
Principal Engineering Agent
Recommended for this workflow
Adjacent modules that complement this skill surface
An error occurred. Please try again later.