Overview
Express Zod API provides a comprehensive error handling system that catches and processes errors from validation, endpoint execution, routing, and result handling. All errors are normalized and handled consistently, ensuring reliable API responses.
Error Flow
Errors are handled at three distinct layers:
- Endpoint Layer: Errors from input validation, middleware, and handler execution
- Routing Layer: Errors from parsing, routing mismatches, and file uploads
- Result Handler Layer: Errors from the Result Handler itself (last resort)
Error Types
Thrown when request data doesn’t match the input schema:
import { InputValidationError } from "express-zod-api";
const endpoint = factory.build({
input: z.object({
age: z.number(),
}),
handler: async ({ input }) => {
// If request has age: "abc", InputValidationError is thrown
},
});
Default behavior:
- Status code:
400 Bad Request
- Includes Zod validation error details
- Handled by the endpoint’s Result Handler
OutputValidationError
Thrown when handler returns data that doesn’t match the output schema:
import { OutputValidationError } from "express-zod-api";
const endpoint = factory.build({
output: z.object({
count: z.number(),
}),
handler: async () => {
// This throws OutputValidationError
return { count: "not a number" };
},
});
Default behavior:
- Status code:
500 Internal Server Error
- Indicates a server-side bug
- Handled by the endpoint’s Result Handler
OutputValidationError means your code has a bug. The handler is returning data that doesn’t match its declared output schema.
HttpError
Explicitly thrown errors with HTTP status codes:
import createHttpError from "http-errors";
const endpoint = factory.build({
handler: async ({ input }) => {
const user = await db.users.find(input.id);
if (!user) {
throw createHttpError(404, "User not found");
}
if (!user.active) {
throw createHttpError(403, "User account is disabled");
}
return user;
},
});
Default behavior:
- Uses the error’s
statusCode property
- Message exposure controlled by
expose property
- Handled by the endpoint’s Result Handler
RoutingError
Thrown during route initialization if routing configuration is invalid:
import { RoutingError } from "express-zod-api";
// Duplicate route
const routing = {
user: getUserEndpoint,
"get /user": getUserEndpoint, // RoutingError: duplicate
};
// Method not supported
const getOnlyEndpoint = factory.build({ method: "get" });
const routing2 = {
"post /users": getOnlyEndpoint, // RoutingError: unsupported method
};
Behavior:
- Thrown at server startup (before handling requests)
- Application should not start with routing errors
- Includes
cause: { method, path }
DocumentationError
Thrown when generating OpenAPI documentation if schemas are incompatible:
import { DocumentationError } from "express-zod-api";
// Thrown during Documentation generation if schema issues exist
Behavior:
- Only occurs during documentation generation
- Indicates schema definition problems
- Includes context about which endpoint caused the issue
ResultHandlerError
Thrown when the Result Handler itself fails:
import { ResultHandlerError } from "express-zod-api";
// If your Result Handler throws an error,
// it's wrapped in ResultHandlerError
Behavior:
- Handled by the Last Resort Handler
- Results in 500 status with plain text response
- Indicates a serious implementation error
Throwing Errors
Using createHttpError
The recommended way to throw errors with specific status codes:
import createHttpError from "http-errors";
const endpoint = factory.build({
handler: async ({ input }) => {
// 400 Bad Request
if (!input.email.includes("@")) {
throw createHttpError(400, "Invalid email format");
}
// 401 Unauthorized
if (!isAuthenticated(input.token)) {
throw createHttpError(401, "Invalid authentication token");
}
// 403 Forbidden
if (!hasPermission(input.userId)) {
throw createHttpError(403, "Insufficient permissions");
}
// 404 Not Found
const user = await db.users.find(input.id);
if (!user) {
throw createHttpError(404, "User not found");
}
// 409 Conflict
if (await db.users.exists({ email: input.email })) {
throw createHttpError(409, "Email already registered");
}
// 429 Too Many Requests
if (rateLimitExceeded(input.userId)) {
throw createHttpError(429, "Rate limit exceeded");
}
// 500 Internal Server Error (default)
throw createHttpError(500, "Something went wrong");
},
});
Error Exposure in Production
Control whether error messages are shown to clients:
import createHttpError from "http-errors";
// NODE_ENV=production
// ✅ Message shown to client (4XX errors expose by default)
throw createHttpError(401, "Invalid credentials");
// Response: "Invalid credentials"
// ✅ Message shown to client (explicit expose)
throw createHttpError(500, "Database connection failed", { expose: true });
// Response: "Database connection failed"
// ❌ Message hidden from client (5XX errors don't expose by default)
throw createHttpError(500, "Secret internal error");
// Response: "Internal Server Error" (generic)
// ❌ Message hidden from client (explicit)
throw createHttpError(401, "Secret auth details", { expose: false });
// Response: "Unauthorized" (generic)
In development mode, all error messages are exposed regardless of the expose property.
Regular Errors
Regular JavaScript errors are converted to 500 Internal Server Error:
const endpoint = factory.build({
handler: async () => {
// Becomes 500 Internal Server Error
throw new Error("Something broke");
// Unhandled promise rejection -> 500
await failingAsyncFunction();
},
});
Error Handling in Different Layers
In Endpoints
Endpoint errors are handled by the endpoint’s Result Handler:
const endpoint = factory.build({
input: z.object({ id: z.string() }),
output: z.object({ name: z.string() }),
handler: async ({ input }) => {
// InputValidationError if input doesn't match schema
// (automatic)
// HttpError from business logic
const item = await db.find(input.id);
if (!item) {
throw createHttpError(404, "Not found");
}
// OutputValidationError if return doesn't match schema
return { name: item.name };
},
});
// All errors handled by the endpoint's Result Handler
// (defaultResultHandler by default)
In Middlewares
Middleware errors stop the chain and go to the Result Handler:
const authMiddleware = new Middleware({
input: z.object({ token: z.string() }),
handler: async ({ input }) => {
// InputValidationError if token missing/invalid type
const user = await verifyToken(input.token);
if (!user) {
// Stops middleware chain, goes to Result Handler
throw createHttpError(401, "Invalid token");
}
return { user };
},
});
In Result Handlers
Result Handler errors are handled by the Last Resort Handler:
const resultHandler = new ResultHandler({
positive: (output) => z.object({ data: output }),
negative: z.object({ error: z.string() }),
handler: ({ error, output, response }) => {
if (error) {
// If this throws, Last Resort Handler catches it
const code = error.statusCode || 500;
return void response.status(code).json({ error: error.message });
}
// If this throws, Last Resort Handler catches it
response.json({ data: output });
},
});
Routing and Parsing Errors
Configured via the errorHandler option:
import { createConfig, defaultResultHandler } from "express-zod-api";
const config = createConfig({
errorHandler: defaultResultHandler, // or custom Result Handler
});
Handles:
- 404 Not Found: No matching route
- 405 Method Not Allowed: Route exists but method not supported
- Parsing errors: Invalid JSON, upload issues, etc.
Custom Error Handler
Create a custom Result Handler for error formatting:
import { ResultHandler, ensureHttpError, logServerError } from "express-zod-api";
import { z } from "zod";
const customErrorHandler = new ResultHandler({
positive: (output) => z.object({ data: output }),
negative: z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.any().optional(),
}),
}),
handler: ({ error, input, output, request, response, logger }) => {
if (error) {
const httpError = ensureHttpError(error);
// Log server errors (5XX)
logServerError(httpError, logger, request, input);
return void response.status(httpError.statusCode).json({
error: {
code: httpError.name,
message: httpError.message,
details: httpError.cause,
},
});
}
response.status(200).json({ data: output });
},
});
// Use in factory
const factory = new EndpointsFactory(customErrorHandler);
// Use as global error handler
const config = createConfig({
errorHandler: customErrorHandler,
});
Error Helpers
ensureHttpError
Converts any error to an HttpError:
import { ensureHttpError } from "express-zod-api";
const httpError = ensureHttpError(error);
// InputValidationError -> 400
// OutputValidationError -> 500
// HttpError -> preserves statusCode
// Other Error -> 500
getMessageFromError
Extracts error message safely:
import { getMessageFromError } from "express-zod-api";
const message = getMessageFromError(error);
// Returns error.message or "Unknown error"
logServerError
Logs server-side errors (5XX):
import { logServerError } from "express-zod-api";
logServerError(httpError, logger, request, input);
// Only logs if error.expose is false (server-side errors)
getPublicErrorMessage
Gets the message safe to show to clients:
import { getPublicErrorMessage } from "express-zod-api";
const publicMessage = getPublicErrorMessage(httpError);
// In production + !expose: generic message for status code
// Otherwise: actual error message
Last Resort Handler
When the Result Handler itself fails, the Last Resort Handler sends a minimal response:
// Response format
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
ResultHandlerError: <error message>
If you see Last Resort Handler responses in production, there’s a critical bug in your Result Handler implementation.
Best Practices
- Use createHttpError: Always throw
HttpError instances with specific status codes
- Validate early: Let Zod schemas handle input validation
- Be specific: Use appropriate status codes (404, 403, 409, etc.)
- Hide sensitive info: Use
expose: false for internal error details
- Log server errors: Use
logServerError() for debugging
- Test error paths: Verify error handling works as expected
- Document errors: List possible error responses in API documentation
Common Patterns
Resource Not Found
const user = await db.users.findById(input.id);
if (!user) {
throw createHttpError(404, "User not found");
}
Unauthorized Access
if (!isValidToken(input.token)) {
throw createHttpError(401, "Invalid or expired token");
}
Forbidden Operation
if (ctx.user.role !== "admin") {
throw createHttpError(403, "Admin privileges required");
}
Resource Conflict
const existing = await db.users.findByEmail(input.email);
if (existing) {
throw createHttpError(409, "Email already registered");
}
Rate Limiting
if (await isRateLimited(ctx.user.id)) {
throw createHttpError(429, "Too many requests, please try again later");
}
Validation with Context
const endpoint = factory.build({
input: z.object({
newEmail: z.string().email(),
}),
handler: async ({ input, ctx }) => {
if (input.newEmail === ctx.user.currentEmail) {
throw createHttpError(400, "New email must be different");
}
// ...
},
});
Testing Error Handling
import { testEndpoint } from "express-zod-api";
import createHttpError from "http-errors";
test("should return 404 when user not found", async () => {
const endpoint = factory.build({
handler: async () => {
throw createHttpError(404, "User not found");
},
});
const { responseMock } = await testEndpoint({ endpoint });
expect(responseMock._getStatusCode()).toBe(404);
expect(responseMock._getJSONData()).toMatchObject({
status: "error",
error: { message: "User not found" },
});
});
test("should return 400 for invalid input", async () => {
const endpoint = factory.build({
input: z.object({ age: z.number() }),
handler: async () => ({}),
});
const { responseMock } = await testEndpoint({
endpoint,
requestProps: { body: { age: "not a number" } },
});
expect(responseMock._getStatusCode()).toBe(400);
});
See Also