fp-ts-backend

fp-ts Backend Patterns

Safety Notice

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

Copy this and send it to your AI assistant to learn

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

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

Source Transparency

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

Related Skills

Related by shared tags or category signals.

General

practical error handling with fp-ts

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-refactor

No summary provided by upstream source.

Repository SourceNeeds Review
General

fp-immutable

No summary provided by upstream source.

Repository SourceNeeds Review
General

managing side effects functionally

No summary provided by upstream source.

Repository SourceNeeds Review