Build Type-Safe Backend Services with fp-ts
Functional programming patterns for building type-safe Node.js and Deno backends using fp-ts ReaderTaskEither, dependency injection, and composable service
Why it matters
Implement robust, testable, and type-safe backend services using functional programming patterns with fp-ts. This asset provides architectural guidance and code examples for dependency injection, service composition, and error handling in Node.js and Deno environments.
Outcomes
What it gets done
Structure backend services using ReaderTaskEither for dependency management and error handling.
Implement functional patterns for dependency injection and service composition.
Define and manage typed domain errors for robust backend applications.
Generate boilerplate code for common backend service operations.
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/ag-fp-backend | bash Capabilities
What this skill does
Writes source code or scripts from a description.
Writes and executes SQL or NoSQL queries on databases.
Analyzes code for bugs, style issues, and improvements.
Traces errors to their root cause and suggests fixes.
Overview
fp-ts Backend Patterns
What it does
Functional backend architecture patterns using fp-ts ReaderTaskEither for dependency injection and typed error handling
How it connects
Building or refactoring Node.js or Deno backends with fp-ts, implementing service composition, or establishing typed backend error handling with ReaderTaskEither
Source README
fp-ts Backend Patterns
Functional programming patterns for building type-safe, testable backend services using fp-ts.
When to Use
- You are building or refactoring a Node.js or Deno backend with fp-ts.
- The task involves dependency injection, service composition, or typed backend errors with
ReaderTaskEither. - You need functional backend architecture patterns rather than isolated utility snippets.
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)
)
)
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.