Error Handling & Validation
How errors are typed, thrown, caught, and surfaced across the stack and where validation lives.
Philosophy
Error handling and validation are first-class concerns, not afterthoughts. The goal is to fail loudly at boundaries (invalid input, unexpected server errors), fail gracefully in the UI (user-facing messages that don't expose internals), and never swallow errors silently.
Further reading on the thinking behind typed, structured errors:
Quick reference
| Concern | Tool | Location |
|---|---|---|
| Input validation | Zod safeParse | src/validation/ |
| Structured error type | ApplicationError | src/classes/errors/ |
| Unknown → string | formatErrorMessage | src/utils/ |
| Route validation 400 | validationErrorResponse | src/app/api/utils/ |
| Route catch-all 500 | routeErrorHandler | src/app/api/utils/ |
| Action result type | ActionResult<T> | src/actions/ |
| UI error display | ErrorDisplay | src/components/common/ |
Validation
Validation schemas live in src/validation/, one file per domain, using Zod.
They are the single source of truth for a domain's input shape.
Schema shape
Each domain file exports:
- A create schema - required fields with sensible defaults
- An update schema - the same base made fully
.partial()so callers send only what changed - Types inferred from the schemas via
z.infer
// 1. Base - no defaults. This is what both schemas are built from.
const exampleItemFields = z.object({
quantity: z.number().int().min(0),
status: z.enum(['draft', 'active', 'archived']),
// ...other fields
});
// 2. Create schema - layers defaults on top of the base.
// Omitting quantity or status gives you 0 / 'draft'.
export const exampleItemSchema = exampleItemFields.extend({
quantity: exampleItemFields.shape.quantity.default(0),
status: exampleItemFields.shape.status.default('draft'),
});
// 3. Update schema - base made partial. Absent = genuinely absent.
export const exampleItemUpdateSchema = exampleItemFields.partial();The base is kept default-free deliberately. Zod's .default() fills a
field when it's absent - even on a .partial() schema. If the base had
quantity.default(0) and you did exampleItemFields.partial(), a PATCH
with only { status: 'active' } would parse to { status: 'active', quantity: 0 },
silently resetting quantity in the database. Keeping the base default-free
means an absent field on an update stays absent.
OpenAPI integration
The .meta() annotations on schema fields double as the OpenAPI contract - the
spec in src/lib/openapi/ feeds these schemas to createDocument, so the
API docs, the validator, and the column shape all derive from one file.
Parsing at the boundary
Always use safeParse at the boundary rather than parse. parse throws and
bypasses your error handling; safeParse returns a discriminated result you
can branch on explicitly:
const parsed = exampleItemSchema.safeParse(await request.json());
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
// parsed.data is fully typed from here
const item = await exampleItemService.create(parsed.data);Error types
ApplicationError
ApplicationError (in src/classes/errors/) is a structured error class for
deliberate, domain-level failures - where your code decides something is
wrong. It extends Error with a context object carrying a scope, the
originating function, and the original error:
throw new ApplicationError('Failed to fetch posts', {
scope: 'posts', // the module/concern, mirrors the logger's scope axis
function: 'PostsService.getAll',
originalError: `${response.status} ${response.statusText}`, // normalised to a string
});Keep originalError a primitive (a string, a status line). Passing a rich
object like a raw Response gets stringified to "[object Response]", which
tells you nothing. Pull out what matters (response.status,
response.statusText) before handing it over.
When to reach for it - and when not to. ApplicationError is for a
deliberate throw:
- A third-party call returns a non-ok response
- A required credential is missing
- A domain invariant is broken.
It is not for wrapping infrastructure faults - see Server Layer → Integration errors
Client side errors are handled by error.tsx, ErrorBoundary, and
ActionResult. If you do need more client-side error observability, that's a
job for a monitoring tool like Sentry, which captures
unhandled exceptions from the browser.
formatErrorMessage
formatErrorMessage (in src/utils/) safely narrows unknown to a string.
Use it anywhere you catch an unknown error and need a displayable message:
import { formatErrorMessage } from '@/utils/format-error-message';
catch (error) {
const message = formatErrorMessage(error); // always returns a string
}When to catch
Open a catch only when you can do something the caller can't. There are three
reasons; if none apply, don't catch - let the error bubble.
| Reason | What you do | Example |
|---|---|---|
| Translate | Rethrow as ApplicationError so callers get one typed shape, not a mystery unknown | integration service on !res.ok |
| Handle | Recover with a real fallback, then carry on | corrupt cache → return defaults |
| Observe | Log once at the boundary that owns the outcome, then shape a response | route handler, server action |
| (none) | Don't catch - let it bubble | plain Drizzle CRUD |
try/catch only exists to deal with a throw - but throwing and catching are
two different jobs. A function that throws does so bare (an if + throw, no
try): it raises the failure and bows out. The try/catch belongs up the
stack, at the boundary that catches that throw. So a bare throw isn't a
missing catch - it's the thing a catch later handles. A failure that
arrives as a value instead - like Zod's safeParse returning
{ success: false } - never needs either; you read it with an if.
Where does it bubble to? "Bubble" means the error unwinds the server call stack until a boundary catches it. The boundary converts it into a value, and that value is what crosses the network and reaches the UI:
PostsService.getAll() throws ApplicationError
↑ bubbles up (no catch here)
route handler / server action ← CAUGHT HERE (the boundary that owns the outcome)
↓ converted to a value
→ route: routeErrorHandler(error) → 500 JSON { error, details }
→ action: catch → return { ok: false, error: '...' }
↓ crosses the network as data, not an exception
client (hook / form) reads the value, renders an error statethrow vs return
Both signal failure - the choice is about who must handle it and when.
throw when failure is exceptional and not this function's job to
resolve. It interrupts flow and bubbles up until a boundary catches it, so
intermediate callers stay clean. Services throw for this reason.
// classes/services/posts/index.ts - the service throws, then bows out.
if (!response.ok) {
throw new ApplicationError('Failed to fetch posts', {
scope: 'posts',
function: 'PostsService.getAll',
originalError: `${response.status} ${response.statusText}`,
});
}return a result when failure is a routine, expected outcome the caller
must handle every time. Modelled as data (ActionResult<T>), the type forces
the caller to branch on it. Actions return for this reason.
// actions/example-item-actions.ts - the action returns a value the form reads.
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}A server action shows both at once: a validation failure is already a value
(return { ok: false }), while a thrown service error is caught and turned
into one. The action's job is to funnel every failure into the single
ActionResult shape the UI renders.
export async function createExampleItem(
input: unknown,
): Promise<ActionResult<ExampleItem>> {
// Failure as a VALUE - safeParse returns { success: false }, so read it with an if.
const parsed = exampleItemSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}
// Failure as a THROW - the service throws, so catch it and turn it into a value.
try {
return { ok: true, data: await exampleItemService.create(parsed.data) };
} catch (error) {
logger.error({
scope: 'example-items',
message: 'Failed to create example item',
error,
});
return { ok: false, error: 'Something went wrong' };
}
}Error handling by layer
Route handlers
Route handlers use two shared utilities from src/app/api/utils/:
validationErrorResponse - returns a consistent 400 with flattened field
errors after a failed safeParse:
if (!parsed.success) {
return validationErrorResponse(parsed.error);
// → 400 { error: 'Validation failed', fieldErrors: { name: ['required'] } }
}routeErrorHandler - catches unexpected errors in the catch block, logs
them, and returns a consistent 500:
try {
const items = await exampleItemService.getAll();
return NextResponse.json({ items }, { status: 200 });
} catch (error) {
return routeErrorHandler(error, 'GET /api/example-items');
// → logs the failure, returns 500 { error, details }
}Server actions
Actions return a discriminated ActionResult<T> instead of throwing, so the
client can branch on success vs failure without a try/catch:
export type ActionResult<TData> =
| { ok: true; data: TData }
| { ok: false; error: string; fieldErrors?: FieldErrors };Validation failures return { ok: false, error, fieldErrors }. Unexpected
errors are caught, logged once at the action boundary, and returned as
{ ok: false, error: 'Something went wrong' } - never leaking internal
details to the client.
Services
Services rethrow without logging. The action or route handler that calls the service is the write boundary and the one place the failure is logged. Logging in both the service and the handler would record the same failure twice under two different sources.
UI
Two error surfaces handle client-side errors:
app/error.tsx- Next.js's reserved segment boundary, fires when a route segment throws during render or hydration. Delegates toErrorDisplay.ErrorBoundary(src/components/common/error-boundary.tsx) - a client boundary for errors thrown after hydration, which Next's boundary misses. UsesErrorDisplayas its fallback.
ErrorDisplay is purely presentational - a message and a route back home. In
production-facing paths, don't pass raw error messages to it; they may leak
implementation details.