Error Handling
How tRPC errors are thrown, formatted, and reported -- including Zod validation, error codes, and Sentry integration.
The API layer handles errors at three levels: automatic Zod input validation, explicit TRPCError throws in router handlers, and Sentry reporting for unexpected failures.
Error Codes
| tRPC Code | HTTP Status | When to Use |
|---|---|---|
UNAUTHORIZED | 401 | No session or expired session. Thrown by protectedProcedure. |
FORBIDDEN | 403 | CASL ability check failed, or user is not a member of the org. Thrown by authorizedProcedure and router handlers. |
PRECONDITION_FAILED | 412 | No active organization selected. Thrown by tenantProcedure. |
NOT_FOUND | 404 | Resource does not exist. Thrown explicitly in router handlers. |
BAD_REQUEST | 400 | Input validation failed. Thrown automatically by tRPC when Zod rejects the input. |
Throwing Errors in Routers
All router errors use TRPCError from @trpc/server. Include a message when the default is not descriptive enough:
// Simple -- code is self-explanatory
if (ctx.ability.cannot("read", "Organization")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// With message -- when the code alone is ambiguous
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot change an owner's role",
});
}
// Not found after a query
const [plan] = await ctx.db
.select()
.from(researchPlan)
.where(eq(researchPlan.id, input.planId))
.limit(1);
if (!plan) throw new TRPCError({ code: "NOT_FOUND" });
Zod Validation Errors
tRPC runs the Zod .input() schema automatically before the handler executes. If validation fails, the client receives a BAD_REQUEST error with structured Zod details.
The error formatter in packages/api/src/trpc.ts augments the standard error shape:
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? z.treeifyError(error.cause) : null,
},
};
},
});
On the client side, a validation error response looks like:
{
"error": {
"message": "...",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"zodError": {
"name": { "errors": ["String must contain at least 1 character(s)"] },
"limit": { "errors": ["Number must be greater than or equal to 1"] }
}
}
}
}
The zodError field uses Zod v4's treeifyError format, which produces a nested object keyed by field path. This makes it straightforward to map errors to form fields on the client.
Sentry Integration
The Next.js route handler at apps/web/src/app/api/trpc/[trpc]/route.ts reports unexpected errors to Sentry while filtering out expected client errors:
const CLIENT_ERROR_CODES = new Set([
"UNAUTHORIZED",
"FORBIDDEN",
"NOT_FOUND",
"BAD_REQUEST",
"PARSE_ERROR",
"PRECONDITION_FAILED",
]);
function handler(req: Request) {
return fetchRequestHandler({
// ...
onError({ error, path, type }) {
if (CLIENT_ERROR_CODES.has(error.code)) return;
Sentry.withScope((scope) => {
scope.setTag("trpc.path", path);
scope.setTag("trpc.type", type);
scope.setTag("trpc.code", error.code);
Sentry.captureException(error);
});
},
});
}
Only errors that are NOT in CLIENT_ERROR_CODES get reported to Sentry. This means:
- A user hitting a FORBIDDEN endpoint does not trigger a Sentry alert
- A BAD_REQUEST from invalid input does not trigger an alert
- An unhandled exception in a router handler (which becomes
INTERNAL_SERVER_ERROR) DOES trigger an alert - Sentry events include the tRPC path, type, and error code as tags for filtering
Request Logging
The publicProcedure middleware logs every request with its outcome, path, type, and duration:
export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => {
const start = performance.now();
const result = await next();
const durationMs = Math.round(performance.now() - start);
if (result.ok) {
ctx.logger.info({ path, type, durationMs }, "tRPC request completed");
} else {
ctx.logger.error(
{ path, type, durationMs, error: result.error.message },
"tRPC request failed",
);
}
return result;
});
Since every other procedure extends from this (directly or indirectly), all requests are logged regardless of auth status. The logger is request-scoped with userId and tenantId already bound from createContext.
Error Handling Summary by Layer
| Layer | What It Catches | Action |
|---|---|---|
Zod .input() | Malformed client input | Automatic BAD_REQUEST with structured zodError |
| Procedure middleware | Missing session, no active org, non-member | UNAUTHORIZED, PRECONDITION_FAILED, FORBIDDEN |
| Router handler | CASL check failure, resource not found, business rules | Explicit TRPCError throw |
publicProcedure logging | All requests (success and failure) | Structured log with path, type, duration |
Route handler onError | Unexpected server errors | Sentry alert with tRPC tags |