Error handling is built on two packages: @repo/errors for the error hierarchy and @repo/safe for the result type.
CircleError HierarchyAll 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> = {};
with(ctx: Record<string, unknown>): this {
Object.assign(this.context, ctx);
return this;
}
toJSON(): Record<string, unknown> {
return { code: this.code, name: this.name, 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 |
CircleError, Never Vanilla Error// Good
import { ValidationError } from "@repo/errors";
throw new ValidationError("Invalid input");
// Bad
throw new Error("Invalid input");
.with() for Context// 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.
Safe<T> Result TypeThe 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 };
safe() Over Try/Catchimport { 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) {
// ...
}
unwrap() for Throw-on-ErrorWhen 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) |
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 checks error"]
I --> J["Maps 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 checks the result:
const { data: patient, error } = await patientsRepo.getById(input.id);
if (error) {
throw new TRPCError({ code: "NOT_FOUND", message: "Patient not found" });
}
return patient;
4. Client receives a typed tRPC error that React Query surfaces as error in the hook.
To add a new domain error:
import { CircleError } from "@repo/errors";
export class MyDomainError extends CircleError {
readonly code = "MY_DOMAIN_ERROR" as const;
readonly domain = "my-domain" as const;
}
Place domain-specific errors in the relevant package (e.g. DB errors in packages/db/src/errors.ts).
Next: UI and Components