Overview
Accountability uses a three-layer error architecture with typed errors flowing from persistence → domain → API → frontend.Persistence Errors → Domain Errors → API Errors → Frontend Errors
PersistenceError AccountNotFound 404 Not Found "Account not found"
SqlError ValidationError 422 Invalid "Invalid account data"
ConnectionError UnauthorizedError 401 Unauthorized "Please sign in"
Key Principle: Errors are values in Effect, not exceptions. They’re tracked in the type signature
Effect<Success, Error, Requirements>.Domain Errors (Core Package)
Schema.TaggedError
All domain errors useSchema.TaggedError for type safety:
import * as Schema from "effect/Schema"
export class AccountNotFound extends Schema.TaggedError<AccountNotFound>()()
"AccountNotFound",
{ accountId: AccountId }
) {
get message(): string {
return `Account not found: ${this.accountId}`
}
}
export class ValidationError extends Schema.TaggedError<ValidationError>()()
"ValidationError",
{ message: Schema.String, field: Schema.Option(Schema.String) }
) {}
export class PeriodClosedError extends Schema.TaggedError<PeriodClosedError>()()
"PeriodClosedError",
{ periodId: FiscalPeriodId, periodName: Schema.String }
) {
get message(): string {
return `Cannot post entries to closed period: ${this.periodName}`
}
}
export class EntryNotBalanced extends Schema.TaggedError<EntryNotBalanced>()()
"EntryNotBalanced",
{ debits: MonetaryAmount, credits: MonetaryAmount }
) {
get message(): string {
return `Entry is not balanced: debits=${this.debits}, credits=${this.credits}`
}
}
Type Guards
// Automatic type guard
export const isAccountNotFound = Schema.is(AccountNotFound)
// Usage
if (isAccountNotFound(error)) {
console.log(`Account ${error.accountId} not found`)
}
Union Error Types
export type AccountError =
| AccountNotFound
| ValidationError
| PersistenceError
export type JournalEntryError =
| JournalEntryNotFound
| ValidationError
| PeriodClosedError
| EntryNotBalanced
| PersistenceError
// Use in service signatures
interface AccountService {
readonly findById: (
id: AccountId
) => Effect.Effect<Account, AccountError>
}
Persistence Errors
Repository Errors
// packages/persistence/src/Errors/RepositoryError.ts
export class PersistenceError extends Schema.TaggedError<PersistenceError>()()
"PersistenceError",
{
operation: Schema.String,
cause: Schema.Unknown
}
) {}
export class EntityNotFoundError extends Schema.TaggedError<EntityNotFoundError>()()
"EntityNotFoundError",
{
entity: Schema.String,
id: Schema.String
}
) {
get message(): string {
return `${this.entity} not found: ${this.id}`
}
}
export class DuplicateKeyError extends Schema.TaggedError<DuplicateKeyError>()()
"DuplicateKeyError",
{
entity: Schema.String,
key: Schema.String
}
) {
get message(): string {
return `${this.entity} already exists: ${this.key}`
}
}
export class ConstraintViolationError extends Schema.TaggedError<ConstraintViolationError>()()
"ConstraintViolationError",
{
constraint: Schema.String,
detail: Schema.String
}
) {}
Mapping SQL Errors
const create = (account: Account) =>
sql`
INSERT INTO accounts ${sql.insert(account)}
RETURNING *
`.pipe(
SqlSchema.single({ Result: AccountRow }),
Effect.map(rowToAccount),
Effect.mapError((sqlError) => {
// Map PostgreSQL errors to domain errors
if (sqlError.message.includes("duplicate key")) {
return new DuplicateKeyError({
entity: "Account",
key: account.accountNumber
})
}
if (sqlError.message.includes("foreign key violation")) {
return new ConstraintViolationError({
constraint: "company_id",
detail: "Company does not exist"
})
}
return new PersistenceError({
operation: "create account",
cause: sqlError
})
})
)
API Errors
HTTP Status Code Mapping
// packages/api/src/Definitions/ApiErrors.ts
import { HttpApiSchema } from "@effect/platform"
export class NotFoundError extends HttpApiSchema.EmptyError<NotFoundError>()(
{ tag: "NotFoundError", status: 404 }
) {}
export class UnauthorizedError extends HttpApiSchema.EmptyError<UnauthorizedError>()(
{ tag: "UnauthorizedError", status: 401 }
) {}
export class ForbiddenError extends HttpApiSchema.EmptyError<ForbiddenError>()(
{ tag: "ForbiddenError", status: 403 }
) {}
export class ValidationError extends Schema.TaggedError<ValidationError>()()
"ValidationError",
{ errors: Schema.Array(Schema.String) },
HttpApiSchema.annotations({ status: 422 })
) {}
export class ConflictError extends Schema.TaggedError<ConflictError>()()
"ConflictError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 409 })
) {}
export class InternalServerError extends Schema.TaggedError<InternalServerError>()()
"InternalServerError",
{ message: Schema.String },
HttpApiSchema.annotations({ status: 500 })
) {}
API Endpoint Error Handling
// Define endpoint with errors
const getAccount = HttpApiEndpoint.get("getAccount", "/accounts/:id")
.setPath(Schema.Struct({ id: AccountId }))
.addSuccess(Account)
.addError(NotFoundError, { status: 404 })
.addError(UnauthorizedError, { status: 401 })
// Implement handler
HttpApiBuilder.handle("getAccount", ({ path }) =>
Effect.gen(function* () {
const service = yield* AccountService
const account = yield* service.findById(path.id)
return yield* Option.match(account, {
onNone: () => Effect.fail(new NotFoundError()),
onSome: Effect.succeed
})
})
)
Automatic Error Mapping
// Domain errors automatically map to API errors
HttpApiBuilder.handle("createAccount", ({ payload }) =>
Effect.gen(function* () {
const service = yield* AccountService
return yield* service.create(payload)
}).pipe(
// Map domain errors to API errors
Effect.mapError((error) => {
if (Schema.is(ValidationError)(error)) {
return new ValidationError({ errors: [error.message] })
}
if (Schema.is(DuplicateKeyError)(error)) {
return new ConflictError({ message: error.message })
}
return new InternalServerError({ message: "Internal error" })
})
)
)
Frontend Errors
API Client Error Handling
import { api } from "@/api/client"
function CreateCompanyForm() {
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
const { data, error: apiError } = await api.POST("/api/v1/companies", {
body: formData
})
if (apiError) {
// Handle specific status codes
if (apiError.status === 422) {
// Validation error
setError(apiError.body?.errors?.join(", ") ?? "Invalid input")
} else if (apiError.status === 409) {
// Conflict
setError("Company with this name already exists")
} else if (apiError.status === 401) {
// Unauthorized
router.navigate({ to: "/login" })
} else {
// Generic error
setError("An error occurred. Please try again.")
}
return
}
// Success
await router.invalidate()
router.navigate({ to: `/companies/${data.id}` })
}
return (
<form onSubmit={handleSubmit}>
{error && <ErrorMessage>{error}</ErrorMessage>}
{/* ... */}
</form>
)
}
User-Friendly Error Messages
const errorMessages: Record<number, string> = {
400: "Invalid request. Please check your input.",
401: "Your session has expired. Please sign in again.",
403: "You don't have permission to perform this action.",
404: "The requested resource was not found.",
409: "This resource already exists.",
422: "Please check your input and try again.",
500: "An error occurred. Please try again later.",
503: "Service temporarily unavailable. Please try again."
}
function getErrorMessage(status: number, body?: { message?: string }): string {
return body?.message ?? errorMessages[status] ?? "An unexpected error occurred."
}
Error Display Components
// ErrorMessage component
interface ErrorMessageProps {
children: React.ReactNode
}
export function ErrorMessage({ children }: ErrorMessageProps) {
return (
<div className="rounded-md bg-red-50 border border-red-200 p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<p className="text-sm text-red-700">{children}</p>
</div>
</div>
</div>
)
}
// ErrorState component (for full-page errors)
interface ErrorStateProps {
title: string
description: string
action?: React.ReactNode
}
export function ErrorState({ title, description, action }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="rounded-full bg-red-100 p-4 mb-4">
<AlertCircle className="h-8 w-8 text-red-600" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
<p className="text-gray-500 text-center max-w-sm mb-6">{description}</p>
{action}
</div>
)
}
Global Error Boundary
import { Component, ErrorInfo, ReactNode } from "react"
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Error caught by boundary:", error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<ErrorState
title="Something went wrong"
description="An unexpected error occurred. Please refresh the page."
action={
<Button onClick={() => window.location.reload()}>
Refresh Page
</Button>
}
/>
)
}
return this.props.children
}
}
// Usage in root
function App() {
return (
<ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
)
}
Error Handling Patterns
catchTag - Handle Specific Error
findAccount(id).pipe(
Effect.catchTag("AccountNotFound", (error) =>
Effect.succeed(createDefaultAccount())
)
)
mapError - Transform Error
findAccount(id).pipe(
Effect.mapError((error) =>
new DomainError({ cause: error, context: "find account" })
)
)
catchAll - Handle All Errors
findAccount(id).pipe(
Effect.catchAll((error) => {
if (Schema.is(AccountNotFound)(error)) {
return Effect.succeed(null)
}
if (Schema.is(ValidationError)(error)) {
return Effect.fail(new ApiValidationError({ cause: error }))
}
return Effect.fail(error) // Propagate unknown errors
})
)
orElse - Fallback Effect
fetchFromPrimary(id).pipe(
Effect.orElse(() => fetchFromCache(id)),
Effect.orElse(() => Effect.succeed(null))
)
Effect.either - Convert to Either<A, E>
const result = yield* Effect.either(riskyOperation())
if (Either.isLeft(result)) {
// Handle error
console.error(result.left)
} else {
// Handle success
console.log(result.right)
}
Anti-Patterns
DON’T Use catchAllCause
NEVER use
Effect.catchAllCause - it catches defects (bugs) which should crash the program.// WRONG - catches defects (bugs)
effect.pipe(
Effect.catchAllCause((cause) =>
Effect.fail(new MyError({ cause: Cause.squash(cause) }))
)
)
// RIGHT - use mapError or catchAll
effect.pipe(
Effect.mapError((error) => new MyError({ cause: error }))
)
DON’T Silently Swallow Errors
// WRONG - silently discards errors
auditLog(entry).pipe(
Effect.catchAll(() => Effect.void)
)
// RIGHT - let error propagate
auditLog(entry)
// RIGHT - transform error
auditLog(entry).pipe(
Effect.mapError((e) => new AuditError({ cause: e }))
)
DON’T Use any or Type Casts
// WRONG
const error = unknownError as ValidationError
// RIGHT
if (Schema.is(ValidationError)(unknownError)) {
// TypeScript knows it's ValidationError here
console.log(unknownError.field)
}
Logging and Monitoring
Effect Logging
import * as Effect from "effect/Effect"
const operation = Effect.gen(function* () {
yield* Effect.logInfo("Starting operation")
const result = yield* riskyOperation().pipe(
Effect.tapError((error) =>
Effect.logError(`Operation failed: ${error.message}`)
)
)
yield* Effect.logInfo("Operation completed")
return result
})
Error Context
findAccount(id).pipe(
Effect.withSpan("findAccount", { attributes: { accountId: id } }),
Effect.tapError((error) =>
Effect.logError(`Failed to find account ${id}`, { error })
)
)
Sentry Integration (Future)
import * as Sentry from "@sentry/node"
const captureError = (error: unknown) =>
Effect.sync(() => {
if (error instanceof Error) {
Sentry.captureException(error)
} else {
Sentry.captureMessage(String(error))
}
})
const operation = riskyOperation().pipe(
Effect.tapError(captureError)
)
Testing Error Scenarios
Unit Tests
it.effect("handles account not found", () =>
Effect.gen(function* () {
const service = yield* AccountService
const result = yield* Effect.either(
service.findById(AccountId.make("nonexistent"))
)
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(Schema.is(AccountNotFound)(result.left)).toBe(true)
}
})
)
E2E Tests
test("shows error for invalid account number", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/accounts/new")
await authenticatedPage.fill('[data-testid="account-number-input"]', "invalid")
await authenticatedPage.click('[data-testid="submit-button"]')
await expect(
authenticatedPage.locator('[data-testid="error-message"]')
).toContainText("Invalid account number")
})
Summary
Error Flow:- Persistence: SQL errors → PersistenceError/EntityNotFoundError
- Domain: Map to business errors (AccountNotFound, ValidationError)
- API: Map to HTTP errors (404, 422, 500)
- Frontend: Display user-friendly messages
- Use
Schema.TaggedErrorfor all domain errors - Never use
catchAllCause(catches defects) - Never silently swallow errors
- Always forward errors or transform them
- Map errors appropriately at each layer boundary
- Display helpful, actionable error messages to users
Next Steps
- Effect Framework - Effect error handling patterns
- Persistence - Database error mapping
- Testing - Testing error scenarios