Validation & Errors
Input validation and error handling follow one flow: Zod schemas validate procedure inputs, validation failures produce tRPC errors, and the error formatter attaches structured details for the client.
Input Validation
Every tRPC procedure can define a Zod schema via .input(). tRPC runs validation automatically before the procedure body executes.
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).optional(),
email: z.email({ error: "Invalid email address" }).optional(),
}),
)
.mutation(({ input }) => {
// Only runs if input passes validation
}),When validation fails, tRPC returns a BAD_REQUEST error with the Zod error attached (see Error Formatter below).
Error Formatter
The tRPC initialization in apps/api/lib/trpc.ts includes a custom error formatter that attaches Zod validation details to the response:
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? flattenError(error.cause) : null,
},
};
},
});This means every error response includes a zodError field – either a flattened Zod error object or null. Clients can use this for field-level error display.
Example error response for a failed validation:
{
"error": {
"message": "...",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"zodError": {
"formErrors": [],
"fieldErrors": {
"email": ["Invalid email address"]
}
}
}
}
}Throwing Errors in Procedures
For business logic errors, throw TRPCError with an appropriate code:
import { TRPCError } from "@trpc/server";
create: protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.organization.findFirst({
where: (o, { eq }) => eq(o.name, input.name),
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "Organization name already taken",
});
}
// ... create organization
}),Common tRPC error codes:
| Code | HTTP Status | When to Use |
|---|---|---|
BAD_REQUEST | 400 | Invalid input (automatic from Zod) |
UNAUTHORIZED | 401 | Not authenticated (automatic from protectedProcedure) |
FORBIDDEN | 403 | Authenticated but lacking permission |
NOT_FOUND | 404 | Resource doesn't exist |
CONFLICT | 409 | Duplicate or conflicting state |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
See the full list in the tRPC error codes reference.
HTTP Error Handling
Hono middleware in apps/api/lib/middleware.ts catches errors outside the tRPC layer:
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
// Merge middleware headers (CORS, security) into the exception response
const res = err.getResponse();
const headers = new Headers(res.headers);
c.res.headers.forEach((v, k) => headers.set(k, v));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
console.error(`[${c.req.method}] ${c.req.path}:`, err);
return c.json({ error: "Internal Server Error" }, 500);
};HTTPException(from Hono) – merges middleware headers (security, CORS) into the exception's response before returning it. Used by Better Auth and webhook handlers.- Unexpected errors – logged and returned as a generic 500.
The tRPC adapter also logs errors independently:
onError({ error, path }) {
console.error("tRPC error on path", path, ":", error);
},Client-Side Error Handling
The frontend app provides three utilities in apps/app/lib/errors.ts for working with errors from both tRPC and Better Auth:
getErrorStatus(error)
Extracts the HTTP status code from various error shapes:
import { getErrorStatus } from "~/lib/errors";
try {
await trpcClient.organization.create.mutate({ name: "" });
} catch (err) {
const status = getErrorStatus(err); // 400
}isUnauthenticatedError(error)
Checks if the error indicates a 401 / UNAUTHORIZED state. Useful for triggering redirects to login:
import { isUnauthenticatedError } from "~/lib/errors";
if (isUnauthenticatedError(error)) {
navigate({ to: "/login" });
}TIP
isUnauthenticatedError checks for HTTP 401 and tRPC UNAUTHORIZED code. It does not match 403 (Forbidden) – that means authenticated but lacking permission.
getErrorMessage(error)
Safely extracts a human-readable message from any thrown value:
import { getErrorMessage } from "~/lib/errors";
const message = getErrorMessage(error);
// "Organization name already taken" or "An unexpected error occurred"