Circle V2 API Docs
    Preparing search index...

    Error Handling

    Error handling is built on two packages: @repo/errors for the error hierarchy and @repo/safe for the result type. At the tRPC boundary, every CircleError is mapped to an HTTP/tRPC code via its error band (see Error Bands at the tRPC Layer).

    All application errors extend CircleError, defined in packages/errors/src/index.ts:

    export abstract class CircleError<Code extends string = string, Domain extends string = string> extends Error {
    abstract readonly code: Code;
    abstract readonly domain: Domain;
    readonly context: Record<string, unknown> = {};
    // Determines how this error is mapped at the tRPC boundary. Defaults to Internal (500).
    readonly band: ErrorBand = ErrorBand.Internal;

    with(ctx: Record<string, unknown>): this {
    Object.assign(this.context, ctx);
    return this;
    }

    toJSON(): Record<string, unknown> {
    return { code: this.code, name: this.name, band: this.band, message: this.message, context: this.context };
    }
    }
    Error Code Package Usage
    UnhandledError UNHANDLED_ERROR @repo/errors Catch-all for unexpected errors
    ValidationError VALIDATION_ERROR @repo/errors Input validation failures
    EnvironmentVariableNotSetError ENVIRONMENT_VARIABLE_NOT_SET_ERROR @repo/errors Missing env var
    DbNotFoundError DB_NOT_FOUND_ERROR @repo/db Record not found in DB
    DbConnectionError DB_CONNECTION_ERROR @repo/db Database connection failure
    UnhandledPostgresError UNHANDLED_POSTGRES_ERROR @repo/db Unexpected PostgreSQL error
    // Good
    import { ValidationError } from "@repo/errors";
    throw new ValidationError("Invalid input");

    // Bad
    throw new Error("Invalid input");
    // Good -- static message, dynamic context attached separately
    throw new UnhandledError("Patient not found").with({ patientId });

    // Bad -- dynamic string interpolation in message
    throw new UnhandledError(`Patient not found: ${patientId}`);

    This convention keeps error messages greppable and avoids accidentally leaking sensitive data into messages.

    Never attach passwords, names, addresses, phone numbers, or other PII to error context.

    Rather than writing if (error instanceof X) throw new TRPCError({...}) in every procedure, each CircleError declares a band. The band is the single source of truth for how the error maps to a tRPC/HTTP code, and whether its message is safe to show the client.

    Band tRPC code HTTP Message exposed?
    Internal (default) INTERNAL_SERVER_ERROR 500 No (generic message)
    BadRequest BAD_REQUEST 400 Yes
    Unauthenticated UNAUTHORIZED 401 Yes
    Forbidden FORBIDDEN 403 Yes
    NotFound NOT_FOUND 404 Yes
    Conflict CONFLICT 409 Yes
    Warning (log & continue) No

    Any error that doesn't opt into a band is treated as Internal (500), and its message is replaced with a generic "Internal server error" so we never leak implementation details. Client-facing bands surface the error's static message (which, per the rules above, must never contain PII).

    Set a band on a concrete error class:

    import { CircleError, ErrorBand } from "@repo/errors";

    export class DbNotFoundError extends DbError {
    readonly code = "DB_NOT_FOUND_ERROR" as const;
    readonly band = ErrorBand.NotFound;
    }

    In most procedures, just unwrap() the Safe result and let the error bubble. The errorBandMiddleware (wired into baseProcedure and authenticatedProcedure) catches the bubbled CircleError and maps it via its band:

    import { unwrap } from "@repo/safe";

    export const renameSavedFilter = authenticatedProcedure
    .input(inputSchema)
    .mutation(async ({ ctx, input }) => {
    const data = unwrap(await savedFiltersRepo.renameById(input.id, ctx.user.user_id, input.name));
    return toSavedViewDto(data);
    });

    A DbNotFoundError becomes NOT_FOUND, a DbConflictError becomes CONFLICT — no per-procedure mapping required.

    When you handle the Safe error explicitly (e.g. to log or branch first), use throwTRPCError instead of building a TRPCError by hand:

    import { throwTRPCError } from "@repo/trpc/server";

    const { data, error } = await savedFiltersRepo.create({ ... });
    if (error) {
    throwTRPCError(error); // returns `never`, so `data` is narrowed below
    }
    return toSavedViewDto(data);

    Both throwTRPCError and the middleware share the same band mapping, so behavior is consistent regardless of which you use. You only need a manual new TRPCError({...}) for transport-level concerns that aren't modeled as a CircleError.

    The Safe<T> type from @repo/safe is a Go-inspired result type for explicit error handling:

    type Safe<T> = SafeSuccess<T> | SafeError;

    type SafeSuccess<T> = { data: T; error: null };
    type SafeError = { data: null; error: CircleError | UnhandledError };
    import { safe } from "@repo/safe";

    // Good -- explicit result handling
    const { data, error } = await safe(async () => await fetchPatients());

    if (error) {
    // handle error
    return;
    }

    // data is typed and non-null here

    // Bad -- implicit exception handling
    try {
    const data = await fetchPatients();
    } catch (error) {
    // ...
    }

    When you want throw-on-error semantics (e.g. in auth code where failure should halt execution):

    import { unwrap } from "@repo/safe";

    const user = await unwrap(userRepo.getById(userId));
    // throws if the repo returned an error
    Function Purpose
    safe(fn, ...args) Wraps a function call, returns Safe<T>
    unwrap(result) Extracts data or throws error
    safeSuccess(data) Creates a SafeSuccess<T>
    safeError(error) Creates a SafeError (normalizes non-CircleError inputs)
    %%{init:{"theme":"dark"}}%% graph TD A["Repo method throws<br/>(e.g. executeTakeFirstOrThrow)"] --> B["createDbRepo catches"] B --> C{"Error type?"} C -->|NoResultError| D["DbNotFoundError"] C -->|Connection error| E["DbConnectionError<br/>(re-thrown, not Safe)"] C -->|PostgrestError| F["UnhandledPostgresError"] C -->|Other| G["UnhandledError"] D --> H["Safe error returned"] F --> H G --> H H --> I["tRPC procedure unwraps result"] I --> J["errorBandMiddleware maps band to TRPCError<br/>(NOT_FOUND, BAD_REQUEST, etc.)"] J --> K["Client receives typed error"]
    %%{init:{"theme":"default"}}%% graph TD A["Repo method throws<br/>(e.g. executeTakeFirstOrThrow)"] --> B["createDbRepo catches"] B --> C{"Error type?"} C -->|NoResultError| D["DbNotFoundError"] C -->|Connection error| E["DbConnectionError<br/>(re-thrown, not Safe)"] C -->|PostgrestError| F["UnhandledPostgresError"] C -->|Other| G["UnhandledError"] D --> H["Safe error returned"] F --> H G --> H H --> I["tRPC procedure unwraps result"] I --> J["errorBandMiddleware maps band to TRPCError<br/>(NOT_FOUND, BAD_REQUEST, etc.)"] J --> K["Client receives typed error"]
    graph TD
      A["Repo method throws<br/>(e.g. executeTakeFirstOrThrow)"] --> B["createDbRepo catches"]
      B --> C{"Error type?"}
      C -->|NoResultError| D["DbNotFoundError"]
      C -->|Connection error| E["DbConnectionError<br/>(re-thrown, not Safe)"]
      C -->|PostgrestError| F["UnhandledPostgresError"]
      C -->|Other| G["UnhandledError"]
      D --> H["Safe error returned"]
      F --> H
      G --> H
      H --> I["tRPC procedure unwraps result"]
      I --> J["errorBandMiddleware maps band to TRPCError<br/>(NOT_FOUND, BAD_REQUEST, etc.)"]
      J --> K["Client receives typed error"]

    1. Repo method -- throws if not found:

    // packages/db/src/repos/patients.repo.ts
    getById: async (id: PatientId) => {
    return await db.selectFrom("patient").selectAll()
    .where("patient_id", "=", id)
    .executeTakeFirstOrThrow(); // throws NoResultError if no row
    },

    2. createDbRepo catches and maps to Safe:

    // Internally, createDbRepo wraps this to:
    // { data: null, error: DbNotFoundError }

    3. tRPC procedure unwraps the result and lets the error bubble. The DbNotFoundError's NotFound band drives the mapping in errorBandMiddleware:

    const patient = unwrap(await patientsRepo.getById(input.id));
    // DbNotFoundError -> NOT_FOUND automatically; no per-procedure mapping needed
    return patient;

    4. Client receives a typed tRPC error (NOT_FOUND) that React Query surfaces as error in the hook.

    To add a new domain error:

    import { CircleError, ErrorBand } from "@repo/errors";

    export class MyDomainError extends CircleError {
    readonly code = "MY_DOMAIN_ERROR" as const;
    readonly domain = "my-domain" as const;
    // Omit `band` to default to Internal (500); set it to map to a specific tRPC code.
    readonly band = ErrorBand.BadRequest;
    }

    Place domain-specific errors in the relevant package (e.g. DB errors in packages/db/src/errors.ts). Choosing the right band is all you need to do — the tRPC layer maps it automatically.


    Next: UI and Components