Vertz treats errors as values, not exceptions. Every operation that can fail returns a Result<T, E> — a discriminated union that makes success and failure explicit in the type system. You handle errors by matching on them, not by wrapping code in try-catch.
Why errors as values?
Exceptions are invisible in TypeScript. A function signature getUser(id: string): User tells you nothing about what can go wrong. The caller has no idea they need to handle NotFoundError or UniqueViolationError until it blows up at runtime.
With Result types, failures are visible in the signature:
The compiler forces you to handle both branches. If you add a new error type to the union, every caller that doesn’t handle it becomes a compile error — not a runtime surprise.
The Result type
Creating results
Handling results
Pattern matching
Exhaustive error matching
matchErr requires a handler for every error code in the union. Miss one and the compiler tells you:
This is the key advantage over exceptions — the compiler enforces completeness.
Unwrapping (tests and scripts only)
unwrap throws on error. Use it only in test setup, scripts, or contexts where you’re certain the
result is Ok. In application code, always handle both branches.
Domain errors
Errors are organized by domain — each boundary has its own error vocabulary.
Database errors
Returned by db.* operations:
| Error code | Meaning | Typical cause |
|---|
NotFound | Record doesn’t exist | .get() with a missing ID |
UNIQUE_VIOLATION | Duplicate unique value | Inserting an email that already exists |
FK_VIOLATION | Referenced record missing | Setting assigneeId to a non-existent user |
NOT_NULL_VIOLATION | Required field missing | Omitting a non-nullable column |
CHECK_VIOLATION | Constraint violated | Value outside allowed range |
Client errors
Returned by the generated SDK on the client side. These use a simpler vocabulary — no database internals leak to the frontend:
| Error code | HTTP status | Meaning |
|---|
ValidationError | 400/422 | Input validation failed |
NotFound | 404 | Resource doesn’t exist |
Conflict | 409 | Duplicate value |
Unauthorized | 401 | Not authenticated |
Forbidden | 403 | Not authorized |
RATE_LIMITED | 429 | Too many requests |
Validation errors
Returned by schema validation (safeParse):
Error boundaries
Errors cross three boundaries in a Vertz app. At each boundary, errors are translated to the appropriate vocabulary:
Database → Server
Database errors map to HTTP status codes automatically:
| DB error | HTTP status |
|---|
NotFound | 404 |
UNIQUE_VIOLATION | 409 |
FK_VIOLATION | 422 |
NOT_NULL_VIOLATION | 422 |
CHECK_VIOLATION | 422 |
The entity framework handles this mapping — you don’t write it manually.
Server → Client
HTTP responses map to client error types:
| HTTP status | Client error |
|---|
| 400 | ValidationError |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 422 | ValidationError |
| 429 | RATE_LIMITED |
The generated SDK handles this mapping — the client receives typed Result<T, ApiError> values.
Why translate?
A database UNIQUE_VIOLATION on column email with constraint users_email_key is an implementation detail. The client doesn’t need to know about constraint names or column internals. It needs to know: “there’s a conflict on the email field.” The boundary mappings strip internal details and produce domain-appropriate errors at each layer.
Custom domain errors
For application-specific errors, extend AppError:
AppError provides:
- A typed
code field for pattern matching
toJSON() for automatic HTTP serialization
- Consistent structure across all custom errors
Infrastructure errors
Not all errors are domain errors. Infrastructure failures — database connection lost, request timeout, pool exhausted — are truly exceptional. These do throw, and are caught by global middleware that returns a 503:
| Error | Meaning |
|---|
ConnectionError | Database unreachable |
PoolExhaustedError | No connections available |
TimeoutError | Operation timed out |
NetworkError | HTTP request failed |
You don’t handle these in business logic. They’re caught at the top level and produce appropriate error responses automatically.
The rule
Expected failures are values. Unexpected failures are exceptions.
- Record not found? Expected — return
Result.
- Unique constraint? Expected — return
Result.
- Validation failed? Expected — return
Result.
- Database crashed? Unexpected — throw, let middleware handle it.
If you can enumerate it, it’s a value. If you can’t predict it, it’s an exception.