Skip to main content

Introduction

Accountability uses Effect-TS as the foundation for all backend code (packages/core, persistence, api). Effect provides type-safe error handling, dependency injection, and composable functional patterns.
Effect is backend only. The frontend (packages/web) uses standard React patterns with no Effect code.

Why Effect?

Type-Safe Error Handling

Problem: JavaScript’s try/catch doesn’t track errors in types.
// Traditional approach - error type is lost
function findAccount(id: string): Account {
  // Throws AccountNotFound, DatabaseError, ValidationError?
  // TypeScript can't help you here!
}

// Effect approach - errors in the type signature
function findAccount(id: AccountId): Effect.Effect<
  Account,
  AccountNotFound | PersistenceError
> {
  // TypeScript knows exactly what can go wrong
}

Composable Effects

Problem: Async operations with error handling get messy fast.
// Traditional approach - nested error handling
async function createJournalEntry(data: CreateEntryData) {
  try {
    const company = await companyRepo.findById(data.companyId)
    if (!company) throw new Error("Company not found")
    
    try {
      const accounts = await accountRepo.findByIds(data.accountIds)
      if (accounts.length !== data.accountIds.length) {
        throw new Error("Some accounts not found")
      }
      
      try {
        const entry = await entryRepo.create(data)
        return entry
      } catch (e) {
        throw new Error("Failed to create entry")
      }
    } catch (e) {
      throw new Error("Failed to load accounts")
    }
  } catch (e) {
    throw new Error("Failed to load company")
  }
}

// Effect approach - flat, composable
const createJournalEntry = (data: CreateEntryData) =>
  Effect.gen(function* () {
    const companyRepo = yield* CompanyRepository
    const accountRepo = yield* AccountRepository
    const entryRepo = yield* JournalEntryRepository
    
    const company = yield* companyRepo.getById(data.companyId)
    const accounts = yield* accountRepo.getByIds(data.accountIds)
    const entry = yield* entryRepo.create(data)
    
    return entry
  })

Dependency Injection

Problem: Mocking dependencies for testing is painful.
// Traditional approach - manual DI
class JournalEntryService {
  constructor(
    private companyRepo: CompanyRepository,
    private accountRepo: AccountRepository,
    private entryRepo: JournalEntryRepository
  ) {}
}

// Effect approach - automatic DI with Context
const createEntry = (data: CreateEntryData) =>
  Effect.gen(function* () {
    // Dependencies injected automatically
    const companyRepo = yield* CompanyRepository
    const accountRepo = yield* AccountRepository
    const entryRepo = yield* JournalEntryRepository
    // ...
  })

Core Concepts

Effect<Success, Error, Requirements>

Effect.Effect<A, E, R>
  // A = Success type (what the effect produces)
  // E = Error type (what can go wrong)
  // R = Requirements (services needed to run)
Examples:
// Infallible effect with no dependencies
const effect1: Effect.Effect<number, never, never> =
  Effect.succeed(42)

// Effect that might fail, no dependencies
const effect2: Effect.Effect<Account, AccountNotFound, never> =
  Effect.fail(new AccountNotFound({ id: "acc_123" }))

// Effect with dependencies
const effect3: Effect.Effect<Account, PersistenceError, AccountRepository> =
  Effect.gen(function* () {
    const repo = yield* AccountRepository
    return yield* repo.findById(accountId)
  })

Effect Generators

Use Effect.gen to write imperative-style code that’s actually functional:
const createAccount = (data: CreateAccountData) =>
  Effect.gen(function* () {
    // Yield effects to unwrap their values
    const repo = yield* AccountRepository
    const company = yield* CompanyRepository
    
    // Validate company exists
    const companyExists = yield* company.findById(data.companyId)
    if (Option.isNone(companyExists)) {
      return yield* Effect.fail(new CompanyNotFound({ id: data.companyId }))
    }
    
    // Create account
    const account = Account.make({
      id: AccountId.make(generateId()),
      companyId: data.companyId,
      name: data.name,
      accountType: data.accountType,
      isActive: true
    })
    
    // Persist
    return yield* repo.create(account)
  })
Always use yield* (not yield) when unwrapping effects. The asterisk is required!

Service Pattern

Defining Services

import * as Context from "effect/Context"
import * as Effect from "effect/Effect"

// Service interface
export interface AccountRepository {
  readonly findById: (
    organizationId: OrganizationId,
    id: AccountId
  ) => Effect.Effect<Option.Option<Account>, PersistenceError>
  
  readonly create: (
    account: Account
  ) => Effect.Effect<Account, PersistenceError>
}

// Service tag for dependency injection
export class AccountRepository extends Context.Tag("AccountRepository")<
  AccountRepository,
  AccountRepository
>() {}

Implementing Services

import { SqlClient } from "@effect/sql"

const make = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient
  
  const findById = (
    organizationId: OrganizationId,
    id: AccountId
  ) =>
    SqlSchema.findOne({
      Request: Schema.Struct({ organizationId, id }),
      Result: AccountRow,
      execute: ({ organizationId, id }) => sql`
        SELECT a.*
        FROM accounts a
        JOIN companies c ON c.id = a.company_id
        WHERE a.id = ${id}
          AND c.organization_id = ${organizationId}
      `
    })({ organizationId, id })
  
  const create = (account: Account) =>
    sql`
      INSERT INTO accounts ${sql.insert(account)}
      RETURNING *
    `.pipe(
      sql.withTransaction,
      SqlSchema.single({ Result: AccountRow })
    )
  
  return {
    findById,
    create
  }
})

export const AccountRepositoryLive = Layer.effect(AccountRepository, make)

Layer Pattern

Layers are Effect’s way of providing services to effects that need them.

Layer Types

Layer.effect - Simple Service Creation

Use when service creation is effectful but doesn’t need cleanup:
const make = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient
  const config = yield* Config
  
  return {
    findById: (id) => { /* ... */ },
    create: (account) => { /* ... */ }
  }
})

export const AccountRepositoryLive = Layer.effect(AccountRepository, make)
//                                    ^^^^^^^^^^^^^ converts Effect to Layer

Layer.scoped - Resource Management

Use when service needs cleanup (connections, subscriptions, file handles):
const make = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient
  
  // Create a PubSub for account change notifications
  const changes = yield* PubSub.unbounded<AccountChange>()
  
  // Listen to Postgres NOTIFY (needs cleanup)
  yield* Effect.forkScoped(
    sql`LISTEN account_changes`.pipe(
      Stream.runForEach((change) => PubSub.publish(changes, change))
    )
  )
  
  return {
    findById: (id) => { /* ... */ },
    subscribe: PubSub.subscribe(changes)
  }
})

export const AccountRepositoryLive = Layer.scoped(AccountRepository, make)
//                                    ^^^^^^^^^^^^^ automatic cleanup

Layer.succeed - Pure Values (Avoid!)

Avoid Layer.succeed and Tag.of - they bypass effectful construction and testing isolation. Use Layer.effect instead.
// BAD - creates service immediately (not lazy)
export const ConfigLive = Layer.succeed(Config, {
  databaseUrl: process.env.DATABASE_URL,
  apiKey: process.env.API_KEY
})

// GOOD - deferred construction, testable
const make = Effect.gen(function* () {
  const databaseUrl = yield* Config.redacted("DATABASE_URL")
  const apiKey = yield* Config.redacted("API_KEY")
  return { databaseUrl, apiKey }
})

export const ConfigLive = Layer.effect(Config, make)

Composing Layers

// Merge layers (parallel composition)
const AppLayers = Layer.mergeAll(
  AccountRepositoryLive,
  CompanyRepositoryLive,
  JournalEntryRepositoryLive
)

// Provide dependencies to layer
const AppLayersWithDb = AppLayers.pipe(
  Layer.provide(PgClientLive),
  Layer.provide(ConfigLive)
)

// Provide layers to an effect
const program = createAccount(data).pipe(
  Effect.provide(AppLayersWithDb)
)

// Run the effect
const result = await Effect.runPromise(program)

Layer Memoization

Layers are memoized by object identity, not by type:
// Same layer reference = shared instance
const layer = AccountRepositoryLive
const composed = Layer.mergeAll(layer, layer) // Single instance

// Different references = separate instances
const layer1 = AccountRepositoryLive
const layer2 = AccountRepositoryLive
const composed2 = Layer.mergeAll(layer1, layer2) // Two instances!
Use Layer.fresh to escape memoization:
const createTestLayer = (config: TestConfig) => {
  const ConfigLayer = Layer.effect(Config, Effect.succeed(config))
  
  // Without Layer.fresh, all tests share the same AuthService instance
  return Layer.fresh(AuthServiceLive).pipe(
    Layer.provide(ConfigLayer)
  )
}

Error Handling

Schema.TaggedError

All domain errors use Schema.TaggedError:
export class AccountNotFound extends Schema.TaggedError<AccountNotFound>()()
  "AccountNotFound",
  { accountId: AccountId }
) {
  get message(): string {
    return `Account not found: ${this.accountId}`
  }
}

// Automatic type guard
export const isAccountNotFound = Schema.is(AccountNotFound)

Error Handling Operators

// catchTag - handle specific error type
findAccount(id).pipe(
  Effect.catchTag("AccountNotFound", (error) =>
    Effect.succeed(createDefaultAccount())
  )
)

// mapError - transform error
findAccount(id).pipe(
  Effect.mapError((error) => new DomainError({ cause: error }))
)

// orElse - fallback effect
findAccount(id).pipe(
  Effect.orElse(() => fetchFromCache(id))
)

// catchAll - handle all errors (preserves error type)
findAccount(id).pipe(
  Effect.catchAll((error) => {
    if (Schema.is(AccountNotFound)(error)) {
      return Effect.succeed(null)
    }
    return Effect.fail(error)
  })
)
NEVER use Effect.catchAllCause - it catches defects (bugs) which should crash the program. Use catchAll or mapError instead.

Three-Layer Error Architecture

Persistence Errors      →  Domain Errors        →  API Errors
PersistenceError          AccountNotFound        404 Not Found
SqlError                  ValidationError        422 Unprocessable Entity
ConnectionError           UnauthorizedError      401 Unauthorized
Mapping errors across layers:
// Persistence → Domain
const findAccount = (id: AccountId) =>
  repo.findById(id).pipe(
    Effect.flatMap(
      Option.match({
        onNone: () => Effect.fail(new AccountNotFound({ id })),
        onSome: Effect.succeed
      })
    )
  )

// Domain → API
HttpApiBuilder.handle("getAccount", ({ path }) =>
  accountService.findById(path.id).pipe(
    // Errors automatically mapped to HTTP status codes
    // AccountNotFound → 404
    // ValidationError → 422
  )
)

Schema Patterns

Schema.Class for Entities

export class Account extends Schema.Class<Account>("Account")({
  id: AccountId,
  companyId: CompanyId,
  accountNumber: AccountNumber,
  name: Schema.NonEmptyTrimmedString,
  accountType: AccountType,
  category: AccountCategory,
  isActive: Schema.Boolean,
  createdAt: Timestamp,
  updatedAt: Timestamp
}) {
  // Custom methods
  get isBalanceSheet(): boolean {
    return ["Asset", "Liability", "Equity"].includes(this.accountType)
  }
  
  get isProfitLoss(): boolean {
    return ["Revenue", "Expense"].includes(this.accountType)
  }
}

// Usage
const account = Account.make({
  id: AccountId.make("acc_123"),
  // ...
})

// Automatic Equal and Hash
Equal.equals(account1, account2) // Structural equality

Branded Types

export const AccountId = Schema.NonEmptyTrimmedString.pipe(
  Schema.brand("AccountId")
)
export type AccountId = typeof AccountId.Type

// Prevents mixing up different ID types
const accountId: AccountId = AccountId.make("acc_123")
const companyId: CompanyId = CompanyId.make("comp_456")

findAccount(companyId) // TypeScript error! ✓

Option and Nullable Fields

// Use Schema.Option for optional fields
export class Account extends Schema.Class<Account>("Account")({
  id: AccountId,
  name: Schema.String,
  parentAccountId: Schema.Option(AccountId), // Option<AccountId>
  description: Schema.Option(Schema.String)  // Option<string>
}) {}

// JSON encoding:
// { parentAccountId: null } or { parentAccountId: "acc_123" }

// Use Schema.OptionFromNullOr for nullable values from external sources
const ApiResponse = Schema.Struct({
  parentId: Schema.OptionFromNullOr(AccountId)
})

Validation and Transformations

// String transformations
export const Email = Schema.String.pipe(
  Schema.trim,
  Schema.lowercase,
  Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/),
  Schema.brand("Email")
)

// Number constraints
export const Percentage = Schema.Number.pipe(
  Schema.greaterThanOrEqualTo(0),
  Schema.lessThanOrEqualTo(100),
  Schema.brand("Percentage")
)

// Date transformations
export const LocalDate = Schema.String.pipe(
  Schema.pattern(/^\d{4}-\d{2}-\d{2}$/),
  Schema.brand("LocalDate")
)

// Custom transformations
export const MonetaryAmount = Schema.transform(
  Schema.String,
  Schema.Number,
  {
    decode: (s) => parseFloat(s),
    encode: (n) => n.toFixed(2)
  }
)

SQL Patterns

SqlSchema Helpers

import { SqlSchema } from "@effect/sql"

// findOne - returns Option<T>
const findById = SqlSchema.findOne({
  Request: AccountId,
  Result: AccountRow,
  execute: (id) => sql`SELECT * FROM accounts WHERE id = ${id}`
})

// findAll - returns Array<T>
const findByCompany = SqlSchema.findAll({
  Request: CompanyId,
  Result: AccountRow,
  execute: (companyId) => sql`
    SELECT * FROM accounts WHERE company_id = ${companyId}
  `
})

// single - expects exactly 1 result (fails if 0 or >1)
const getById = SqlSchema.single({
  Request: AccountId,
  Result: AccountRow,
  execute: (id) => sql`SELECT * FROM accounts WHERE id = ${id}`
})

// void - for INSERT/UPDATE/DELETE with no return
const deleteById = SqlSchema.void({
  Request: AccountId,
  execute: (id) => sql`DELETE FROM accounts WHERE id = ${id}`
})

SQL Helpers

// Insert
sql`INSERT INTO accounts ${sql.insert(account)}`
sql`INSERT INTO accounts ${sql.insert([account1, account2, account3])}`

// Update
sql`UPDATE accounts SET ${sql.update({ name: "New Name" })} WHERE id = ${id}`

// IN clause
sql`SELECT * FROM accounts WHERE id IN ${sql.in(accountIds)}`

// AND conditions
sql`SELECT * FROM accounts WHERE ${sql.and([
  sql`company_id = ${companyId}`,
  sql`is_active = true`
])}`

Transactions

const createWithAudit = (account: Account) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient
    
    return yield* sql.withTransaction(
      Effect.gen(function* () {
        yield* sql`INSERT INTO accounts ${sql.insert(account)}`
        yield* sql`INSERT INTO audit_log ${sql.insert({
          entityType: "account",
          entityId: account.id,
          action: "create"
        })}`
        return account
      })
    )
  })

Testing with @effect/vitest

Test Variants

import { describe, it, expect, layer } from "@effect/vitest"

// it.effect - Most tests (TestClock for fast time)
it.effect("processes after delay", () =>
  Effect.gen(function* () {
    const fiber = yield* Effect.fork(
      Effect.sleep(Duration.minutes(5)).pipe(Effect.map(() => "done"))
    )
    yield* TestClock.adjust(Duration.minutes(5)) // No real waiting!
    const result = yield* Fiber.join(fiber)
    expect(result).toBe("done")
  })
)

// it.live - When you need real time/IO
it.live("fetches from real API", () =>
  Effect.gen(function* () {
    const response = yield* Effect.promise(() => fetch("https://api.example.com"))
    expect(response.status).toBe(200)
  })
)

// it.scoped - When you need resource cleanup
it.scoped("manages resource lifecycle", () =>
  Effect.gen(function* () {
    const resource = yield* acquireResource()
    // resource automatically released after test
    expect(resource.isOpen).toBe(true)
  })
)

Sharing Layers Between Tests

layer(AccountServiceLive)("AccountService", (it) => {
  it.effect("finds account by id", () =>
    Effect.gen(function* () {
      const service = yield* AccountService
      const account = yield* service.findById(testAccountId)
      expect(account.name).toBe("Cash")
    })
  )
  
  it.effect("creates new account", () =>
    Effect.gen(function* () {
      const service = yield* AccountService
      const account = yield* service.create(newAccountData)
      expect(account.id).toBeDefined()
    })
  )
})

Property-Based Testing

import { FastCheck } from "effect"

it.effect.prop(
  "account creation is idempotent",
  [Schema.String, FastCheck.integer()],
  ([name, accountNumber]) =>
    Effect.gen(function* () {
      const service = yield* AccountService
      const account1 = yield* service.create({ name, accountNumber })
      const account2 = yield* service.create({ name, accountNumber })
      expect(account1.id).toBe(account2.id)
    })
)

Critical Rules

Violating these rules will cause bugs or make testing impossible:

1. Never Use any or Type Casts

// WRONG
const account = data as Account
const id: any = someValue

// RIGHT
const account = yield* Schema.decodeUnknown(Account)(data)
const id = AccountId.make(someValue)

2. Never Use catchAll on never Error Type

// WRONG - effect never fails, catchAll is useless
Effect.succeed(42).pipe(
  Effect.catchAll((e) => Effect.fail(new MyError({ cause: e })))
)

// RIGHT - only catch when E is not never
findAccount(id).pipe(
  Effect.catchAll((e) => Effect.fail(new MyError({ cause: e })))
)

3. Never Use disableValidation: true

// WRONG - banned by lint rule
const account = Account.make(data, { disableValidation: true })

// RIGHT - let Schema validate
const account = Account.make(data)

4. Never Use *FromSelf Schemas

// WRONG
parentId: Schema.OptionFromSelf(AccountId)

// RIGHT
parentId: Schema.Option(AccountId)

5. Never Silently Swallow Errors

// WRONG - silently discards errors
yield* auditLog(entry).pipe(
  Effect.catchAll(() => Effect.void)
)

// RIGHT - let error propagate or transform it
yield* auditLog(entry) // Error visible in types
yield* auditLog(entry).pipe(
  Effect.mapError((e) => new AuditError({ cause: e }))
)

Next Steps

Build docs developers (and LLMs) love