docs/api/validation-errors.md
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.
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).
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"]
}
}
}
}
}
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.
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.The tRPC adapter also logs errors independently:
onError({ error, path }) {
console.error("tRPC error on path", path, ":", error);
},
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"