EEPNext.js Template
Conventions

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

ConcernToolLocation
Input validationZod safeParsesrc/validation/
Structured error typeApplicationErrorsrc/classes/errors/
Unknown → stringformatErrorMessagesrc/utils/
Route validation 400validationErrorResponsesrc/app/api/utils/
Route catch-all 500routeErrorHandlersrc/app/api/utils/
Action result typeActionResult<T>src/actions/
UI error displayErrorDisplaysrc/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.

ReasonWhat you doExample
TranslateRethrow as ApplicationError so callers get one typed shape, not a mystery unknownintegration service on !res.ok
HandleRecover with a real fallback, then carry oncorrupt cache → return defaults
ObserveLog once at the boundary that owns the outcome, then shape a responseroute handler, server action
(none)Don't catch - let it bubbleplain 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 state

throw 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 to ErrorDisplay.
  • ErrorBoundary (src/components/common/error-boundary.tsx) - a client boundary for errors thrown after hydration, which Next's boundary misses. Uses ErrorDisplay as 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.


On this page