Skip to main content
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:
// Exception style — failure is invisible
function getUser(id: string): User { ... }

// Result style — failure is part of the contract
function getUser(id: string): Result<User, NotFoundError> { ... }
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

import { ok, err, type Result } from 'vertz/errors';

type Result<T, E> = Ok<T> | Err<E>;

// Ok branch
{ ok: true, data: T }

// Err branch
{ ok: false, error: E }

Creating results

import { ok, err } from 'vertz/errors';

// Success
return ok(user);

// Failure
return err(createNotFoundError('users'));

Handling results

const result = await db.users.get({ where: { id: userId } });

if (result.ok) {
  console.log(result.data.name); // typed as User
} else {
  console.log(result.error.code); // typed as error union
}

Pattern matching

import { match } from 'vertz/errors';

const message = match(result, {
  ok: (user) => `Hello, ${user.name}!`,
  err: (error) => `Error: ${error.message}`,
});

Exhaustive error matching

matchErr requires a handler for every error code in the union. Miss one and the compiler tells you:
import { matchErr } from 'vertz/errors';

const response = matchErr(result, {
  ok: (user) => json({ data: user }, 201),
  NOT_FOUND: (e) => json({ error: 'User not found' }, 404),
  UNIQUE_VIOLATION: (e) => json({ error: 'Email already exists', field: e.column }, 409),
  // If the error union includes FK_VIOLATION and you don't list it here,
  // TypeScript shows a compile error
});
This is the key advantage over exceptions — the compiler enforces completeness.

Transforming results

import { map, flatMap, unwrapOr } from 'vertz/errors';

// Transform the success value
const nameResult = map(result, (user) => user.name);

// Chain operations that each return Result
const profileResult = await flatMap(result, async (user) => {
  return await db.profiles.get({ where: { userId: user.id } });
});

// Provide a fallback
const name = unwrapOr(result, 'Unknown');

Unwrapping (tests and scripts only)

import { unwrap } from 'vertz/errors';

// Throws if result is Err — only use in tests/scripts
const user = unwrap(result);
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 codeMeaningTypical cause
NotFoundRecord doesn’t exist.get() with a missing ID
UNIQUE_VIOLATIONDuplicate unique valueInserting an email that already exists
FK_VIOLATIONReferenced record missingSetting assigneeId to a non-existent user
NOT_NULL_VIOLATIONRequired field missingOmitting a non-nullable column
CHECK_VIOLATIONConstraint violatedValue outside allowed range
const result = await db.users.create({
  data: { email: 'alice@example.com', name: 'Alice' },
});

if (!result.ok) {
  switch (result.error.code) {
    case 'UNIQUE_VIOLATION':
      console.log(`Duplicate: ${result.error.column}`);
      break;
    case 'NOT_NULL_VIOLATION':
      console.log(`Missing: ${result.error.column}`);
      break;
  }
}

Client errors

Returned by the generated SDK on the client side. These use a simpler vocabulary — no database internals leak to the frontend:
Error codeHTTP statusMeaning
ValidationError400/422Input validation failed
NotFound404Resource doesn’t exist
Conflict409Duplicate value
Unauthorized401Not authenticated
Forbidden403Not authorized
RATE_LIMITED429Too many requests

Validation errors

Returned by schema validation (safeParse):
const result = schema.safeParse(input);

if (!result.ok) {
  for (const issue of result.error.issues) {
    console.log(`${issue.path.join('.')}: ${issue.message}`);
    // "name: String must contain at least 1 character(s)"
    // "age: Expected number, received string"
  }
}

Error boundaries

Errors cross three boundaries in a Vertz app. At each boundary, errors are translated to the appropriate vocabulary:
Database → Server → Client

Database → Server

Database errors map to HTTP status codes automatically:
DB errorHTTP status
NotFound404
UNIQUE_VIOLATION409
FK_VIOLATION422
NOT_NULL_VIOLATION422
CHECK_VIOLATION422
The entity framework handles this mapping — you don’t write it manually.

Server → Client

HTTP responses map to client error types:
HTTP statusClient error
400ValidationError
401Unauthorized
403Forbidden
404NotFound
409Conflict
422ValidationError
429RATE_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:
import { AppError } from 'vertz/errors';

class InsufficientBalanceError extends AppError<'INSUFFICIENT_BALANCE'> {
  constructor(
    public readonly required: number,
    public readonly available: number,
  ) {
    super('INSUFFICIENT_BALANCE', `Need ${required}, have ${available}`);
  }
}
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:
ErrorMeaning
ConnectionErrorDatabase unreachable
PoolExhaustedErrorNo connections available
TimeoutErrorOperation timed out
NetworkErrorHTTP 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.