The ZudoJS core Core
The ZudoJS error system
Handle failures the ZudoJS way. Tell expected errors from bugs, use and extend the error classes in @zudojs/errors, choose codes and categories, serialize errors safely for clients and fully for logs, and turn every error in the Task API into the right HTTP response.
Two kinds of failure
Things go wrong in a backend all the time, and most of it is normal. It helps to sort failures into two kinds:
- Expected failures, also called operational errors: the client sent bad data, asked for a task that does not exist, or tried to create a duplicate. Your code knows these can happen. The client did something wrong, deserves a clear message, and gets a 4xx status.
- Bugs, also called programmer errors: a
TypeError, a query with a typo, anundefinedwhere there should be an object. Nobody planned them. The client gets a 500 with a generic message, and you get the details in your logs, so you can fix the bug.
There is a second way to sort them: who throws them. Framework errors come from ZudoJS packages: SchemaError from a schema, RuntimeStartError from the runtime, CircularDependencyError from the container, HttpError from the HTTP layer. Application errors are the ones your own code throws for its own rules: NotFoundError, ConflictError, or classes you write yourself. All of them extend one class, BaseError from @zudojs/errors, so one piece of code can handle them all:
import { ConflictError, DomainError, NotFoundError, normalizeToBaseError, ValidationError } from "@zudojs/errors";
import { schema } from "@zudojs/schema";
function schemaFailure(): unknown {
try {
schema.object({ title: schema.string().min(3) }).parse({ title: "no" });
} catch (error) {
return error;
}
}
const errors = [
new NotFoundError("Task 9 not found"),
new ConflictError("A task called \"Buy milk\" already exists"),
new ValidationError("The due date is in the past"),
new DomainError("A finished task cannot be reopened"),
normalizeToBaseError(schemaFailure()),
normalizeToBaseError(new TypeError("Cannot read properties of undefined (reading 'title')")),
];
for (const e of errors) {
console.log(e.name.padEnd(16), e.code.padEnd(24), e.statusCode, e.category.padEnd(10), "expose:", e.expose, "operational:", e.isOperational);
}
npx tsx kinds.ts and of the browser terminalNotFoundError ERR_RESOURCE_NOT_FOUND 404 resource expose: true operational: true ConflictError ERR_CONFLICT 409 conflict expose: true operational: true ValidationError ERR_VALIDATION_FAILED 400 validation expose: true operational: true DomainError ERR_OPERATION_FAILED 422 business expose: true operational: true SchemaError ERR_SCHEMA_VALIDATION 400 validation expose: true operational: true BaseError ERR_INTERNAL_ERROR 500 system expose: false operational: false
Every error carries the same fields:
statusCode: the HTTP status to answer with.code: a stable, machine-readable name. Clients and tests checkcode, nevermessage, so you can reword messages freely.category: which area failed. It helps to group errors on a dashboard.expose:trueif the message is safe to show a client. By default it istruefor 4xx errors andfalsefor 5xx errors, because a server error's message often describes your internals.isOperational:truefor expected failures,falsefor bugs.
The last line is the interesting one. normalizeToBaseError takes anything that was thrown (in JavaScript you can throw any value, so a catch gets unknown) and turns it into a BaseError. A BaseError passes through unchanged. Anything else, such as that TypeError, becomes a hidden, non-operational 500 with code ERR_INTERNAL_ERROR: a bug.
Your own error classes
When no ready-made class fits a rule of your application, write one. Extend BaseError and set everything in the constructor, so every place that throws it gets the same code and status. The Task API gets a rule: a task that is already done cannot be completed again.
import { BaseError, ErrorCategory, ErrorSeverity, serializePublicError } from "@zudojs/errors";
export class TaskAlreadyDoneError extends BaseError {
constructor(taskId: number) {
super(`Task ${taskId} is already done`, {
code: "ERR_TASK_ALREADY_DONE",
category: ErrorCategory.BUSINESS,
severity: ErrorSeverity.INFO,
statusCode: 409,
metadata: { taskId },
});
}
}
function complete(task: { id: number; done: boolean }): void {
if (task.done) {
throw new TaskAlreadyDoneError(task.id);
}
task.done = true;
}
try {
complete({ id: 3, done: true });
} catch (error) {
if (error instanceof TaskAlreadyDoneError) {
console.log(String(error));
console.log(error.statusCode, error.expose, error.getMetadata("taskId"));
console.log(serializePublicError(error));
}
}
npx tsx custom.ts and of the browser terminalTaskAlreadyDoneError [ERR_TASK_ALREADY_DONE]: Task 3 is already done
409 true 3
{
code: 'ERR_TASK_ALREADY_DONE',
message: 'Task 3 is already done',
category: 'business',
statusCode: 409,
metadata: { taskId: 3 }
}- The
codeis your own string. Prefix it (ERR_) and keep it stable: clients will writeif (body.error.code === "ERR_TASK_ALREADY_DONE"). metadataholds facts about the failure. It is copied and frozen, so nobody can change it later. Only put in what a client may see, or what the log needs; never a password or a token.namewas set to the class name for you.- 409 Conflict fits: the request clashes with the current state of the task.
Put the rules and their errors in the service, not in the routes. The service does not know about HTTP; it only says "this task is already done", and the status code travels with the error.
Validation errors
You have met two classes for bad input, both with status 400:
SchemaError, thrown byparse. Itsissueslist says which fields failed and why.ValidationError, for checks you write yourself. You give it the list of problems, built with helpers such asrequiredFieldIssueandinvalidFieldIssue.
import { invalidFieldIssue, requiredFieldIssue, ValidationError } from "@zudojs/errors";
import { isSchemaValidationError, schema } from "@zudojs/schema";
try {
schema.object({ title: schema.string().min(3), done: schema.boolean() }).parse({ title: "no" });
} catch (error) {
if (isSchemaValidationError(error)) {
console.log(error.code, error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));
}
}
const error = new ValidationError("The import file has problems", {
issues: [requiredFieldIssue("title"), invalidFieldIssue("priority", "Priority must be low, normal or high")],
});
console.log(error.code, error.issueCount, error.issues.map((i) => `${i.field}: ${i.message}`));
npx tsx validation-errors.ts and of the browser terminalERR_SCHEMA_VALIDATION [ 'title: String must be at least 3 characters', 'done: Required field missing: done' ] ERR_VALIDATION_FAILED 2 [ 'title: title is required.', 'priority: Priority must be low, normal or high' ]
isSchemaValidationError, from @zudojs/schema, checks that the error is a SchemaError and types its issues, as in the validation lesson. ValidationError.issues is typed already.
HTTP errors
@zudojs/http has its own HttpError class and shortcuts such as badRequest(), notFound() and tooManyRequests(). HttpError also extends BaseError, so it has the same fields:
import { badRequest, HttpError, notFound } from "@zudojs/http";
import { isBaseError, NotFoundError } from "@zudojs/errors";
const errors = [
badRequest("The request body is not valid JSON"),
notFound("No such route"),
new HttpError(415, "Send JSON"),
new NotFoundError("Task 9 not found"),
];
for (const e of errors) {
console.log(e.name.padEnd(14), e.statusCode, e.code.padEnd(24), isBaseError(e));
}
npx tsx http-errors.tsHttpError 400 BAD_REQUEST true HttpError 404 NOT_FOUND true HttpError 415 UNSUPPORTED_MEDIA_TYPE true NotFoundError 404 ERR_RESOURCE_NOT_FOUND true
new HttpError(415, ...) got its code from the status. Both families work, so which should you use where? A simple rule:
- Services throw
@zudojs/errorsclasses:NotFoundError,ConflictError, your ownTaskAlreadyDoneError. They describe what went wrong in your application's terms, and the same service could be used from a queue worker or a command-line tool, where HTTP means nothing. - The HTTP layer (routes,
readJson, middleware) may throwHttpErrorfor problems that only exist in HTTP: a wrongContent-Type(415), unreadable JSON (400).
What the client sees, and what your logs see
Serializing an error means turning it into plain data, for a response body or a log entry. The two audiences need very different things:
- The client needs a code, a safe message, and nothing else. A stack trace, an SQL query or a file path tells an attacker how your system is built.
- Your logs need everything: the stack, the cause, the metadata, so you can find the bug.
serializePublicError(error) is for clients. error.toJSON() is for logs. Here both look at a database failure whose metadata holds the failing SQL and, by mistake, a password:
import { databaseQueryError, serializePublicError } from "@zudojs/errors";
const error = databaseQueryError('relation "tasks" does not exist', {
metadata: { sql: "SELECT * FROM tasks WHERE id = $1", password: "pa55word" },
});
console.log("client:", serializePublicError(error));
const forLogs = error.toJSON();
console.log("logs:", forLogs.message, forLogs.metadata);
console.log("logs also get a stack trace:", typeof forLogs.stack, forLogs.stack?.split("\n")[0]);
npx tsx serialize.ts and of the browser terminalclient: {
code: 'ERR_DATABASE_QUERY',
message: 'An unexpected error occurred.',
category: 'database',
statusCode: 500
}
logs: relation "tasks" does not exist {
sql: 'SELECT * FROM tasks WHERE id = $1',
password: '[REDACTED]',
operation: 'query'
}
logs also get a stack trace: string DatabaseError: relation "tasks" does not existBecause a database error is a 500, expose is false: the client got a generic message and no metadata at all. The log copy kept the real message, the SQL and the stack trace, and still replaced the password: both serializers redact keys whose names look secret.
NEVER SEND toJSON() TO A CLIENT
A response such as.json(error) or .json(error.toJSON()) sends the stack trace and the raw metadata to whoever made the request. Always build client bodies from serializePublicError, or from your own fields as below.ErrorHandler packs the usual steps into one call: normalize whatever was thrown, report it (to your logger or an error-tracking service), and build the public body:
import { ErrorHandler, NotFoundError } from "@zudojs/errors";
const handler = new ErrorHandler({
reporter: (error, context) => console.log(`[report] ${error.code} operational=${error.isOperational} request=${context?.requestId}`),
});
console.log(await handler.handlePublic(new NotFoundError("Task 9 not found", { metadata: { taskId: 9 } }), { requestId: "req-1" }));
console.log(await handler.handlePublic(new Error("connect ECONNREFUSED 10.0.0.5:5432"), { requestId: "req-2" }));
npx tsx error-handler.ts and of the browser terminal[report] ERR_RESOURCE_NOT_FOUND operational=true request=req-1
{
code: 'ERR_RESOURCE_NOT_FOUND',
message: 'Task 9 not found',
statusCode: 404,
requestId: 'req-1',
details: { taskId: 9 }
}
[report] ERR_INTERNAL_ERROR operational=false request=req-2
{
code: 'ERR_INTERNAL_ERROR',
message: 'An unexpected error occurred.',
statusCode: 500,
requestId: 'req-2'
}The second error was a plain Error mentioning an internal address. The client body says nothing about it; the reporter saw it as a non-operational error, which is exactly the kind you want an alert for.
Errors in @zudojs/http
You have been relying on simple error handling since Routes, requests and responses, built into @zudojs/http and copied by the Task API's generated code: an error with a status becomes a response with that status and {"error": message, "code": code}; anything else becomes a plain 500. That is safe, but the Task API wants more:
- validation errors should list their issues, not just say "Validation failed",
- every error body should have the same shape, with the request id, so a user can report it and you can find the log line,
- bugs should be logged with their full details.
So you write an error handler: one function that turns any thrown value into a response. The natural place for it is a middleware around the router. When a route throws, await next() throws that very same error, so an ordinary try/catch and instanceof work there:
import { createRequestContext, createResponseContext, createRouter, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";
import { normalizeToBaseError, NotFoundError, serializePublicError } from "@zudojs/errors";
const router = createRouter();
router.get("/tasks/:id", (ctx) => {
throw new NotFoundError(`Task ${ctx.params.id} not found`);
});
router.get("/crash", () => {
throw new TypeError("Cannot read properties of undefined (reading 'title')");
});
const handleErrors: HttpMiddleware = async (context, next) => {
try {
return await next();
} catch (thrown) {
console.log("caught:", (thrown as Error).name, "| NotFoundError?", thrown instanceof NotFoundError);
const error = normalizeToBaseError(thrown);
const { code, message } = serializePublicError(error);
return createResponseContext({ status: error.statusCode }).json({ error: { code, message } });
}
};
const pipeline = new HttpMiddlewarePipeline();
pipeline.use(handleErrors);
pipeline.use(async (context) => (await router.dispatch(context.request)).response);
for (const url of ["/tasks/9", "/crash"]) {
const response = await pipeline.execute(createRequestContext({ method: "GET", url }), createResponseContext());
console.log(response.status, response.body);
}
npx tsx error-middleware.tscaught: NotFoundError | NotFoundError? true
404 {"error":{"code":"ERR_RESOURCE_NOT_FOUND","message":"Task 9 not found"}}
caught: TypeError | NotFoundError? false
500 {"error":{"code":"ERR_INTERNAL_ERROR","message":"An unexpected error occurred."}}The middleware received the real NotFoundError, and the TypeError bug became a 500 whose message says nothing about your code. (Before @zudojs/http 1.4.0, an error was wrapped in an HttpMiddlewareError on its way back through the pipeline, so older code caught errors inside the router step instead.)
Put it in the Task API
The Task API already has an error handler: the CLI wrote one. In src/server.ts, the dispatch step that runs the router catches whatever a route throws:
const dispatch: HttpMiddleware = async (context) => {
try {
return (await router.dispatch(context.request, { signal: context.signal })).response;
} catch (error) {
const response = errorResponse(error);
if (response === undefined) throw error;
return response;
}
};
errorResponse, in src/utils/http.ts, answers an error with an exposed 4xx status with {"error": message, "code": code}. For anything else it returns undefined, the error is thrown on, and @zudojs/http answers a plain 500. That is safe, and it is the behaviour you have seen since Routes, requests and responses. The step also sits in the right place: last in the pipeline, after CORS, so an error answer is an ordinary response to the middleware around it and gets the CORS headers too. Without them, the browser would hide the error body from your web app.
Replace errorResponse with a handler that does the rest of this lesson. Create src/utils/errors.ts:
import { normalizeToBaseError, serializePublicError } from "@zudojs/errors";
import type { BaseError } from "@zudojs/errors";
import { createResponseContext } from "@zudojs/http";
import type { HttpResponseContext } from "@zudojs/http";
import type { Logger } from "@zudojs/logger";
import { isSchemaValidationError } from "@zudojs/schema";
export type ErrorReporter = (error: BaseError, requestId: string) => void;
export function logBugs(logger: Logger): ErrorReporter {
return (error, requestId) => {
logger.error("request failed", { requestId, error: error.toJSON() });
};
}
export function toErrorResponse(thrown: unknown, requestId: string, report: ErrorReporter): HttpResponseContext {
const error = normalizeToBaseError(thrown);
if (!error.isOperational || error.statusCode >= 500) {
report(error, requestId);
}
const { code, message } = serializePublicError(error);
const issues = isSchemaValidationError(thrown)
? thrown.issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message }))
: undefined;
return createResponseContext({ status: error.statusCode })
.setHeader("x-request-id", requestId)
.json({ error: message, code, requestId, ...(issues ? { issues } : {}) });
}
- It always answers, so no error is thrown on to a plain 500 any more. A bug still gets a 500, with the generic message from
serializePublicError. - Only bugs are reported. A 404 is not something to fix, and logging every one would bury the real problems. The
reportfunction is a parameter, so a test can see what would be logged;logBugsbuilds the real one from a logger. - The body keeps the field names the generated code already uses,
errorandcode, and adds therequestId(also sent as thex-request-idheader). For aSchemaErrorit adds theissues, shaped like the generatedvalidationFailedhelper: apathand amessageper problem. Nothing else from the error reaches the client.
In src/server.ts, import the two functions instead of errorResponse, build the reporter from the runtime's logger, and let dispatch return the answer:
import { logBugs, toErrorResponse } from "./utils/errors.js";
import { securityHeaders } from "./utils/http.js";
// ...
const reportBug = logBugs(runtime.context.logger);
const dispatch: HttpMiddleware = async (context) => {
try {
return (await router.dispatch(context.request, { signal: context.signal })).response;
} catch (error) {
return toErrorResponse(error, context.request.id, reportBug);
}
};
Nothing else uses errorResponse now, so delete it from src/utils/http.ts. This check script builds the same dispatch step around a stand-in router that fails in every possible way, and prints what the reporter receives:
import { ConflictError, NotFoundError } from "@zudojs/errors";
import { badRequest, createRequestContext, createResponseContext, createRouter, HttpMiddlewarePipeline } from "@zudojs/http";
import type { HttpMiddleware } from "@zudojs/http";
import { schema } from "@zudojs/schema";
import { toErrorResponse } from "./utils/errors.js";
const router = createRouter();
router.get("/missing", () => { throw new NotFoundError("Task 9 not found"); });
router.get("/conflict", () => { throw new ConflictError('A task called "Buy milk" already exists'); });
router.get("/invalid", () => {
return schema.object({ title: schema.string().min(3), done: schema.boolean() }).parse({ title: "no" });
});
router.get("/json", () => { throw badRequest("The request body is not valid JSON."); });
router.get("/bug", () => { throw new TypeError("Cannot read properties of undefined (reading 'title')"); });
const dispatch: HttpMiddleware = async (context) => {
try {
return (await router.dispatch(context.request)).response;
} catch (error) {
return toErrorResponse(error, context.request.id, (e, id) => {
console.log(` [report] ${e.code} (${(e.cause as Error).message}) request=${id.length} chars`);
});
}
};
const pipeline = new HttpMiddlewarePipeline({ middlewares: [dispatch] });
for (const url of ["/missing", "/conflict", "/invalid", "/json", "/bug"]) {
const response = await pipeline.execute(createRequestContext({ method: "GET", url }), createResponseContext());
const { requestId, ...rest } = JSON.parse(String(response.body)) as Record<string, unknown>;
console.log(url, response.status, JSON.stringify(rest), requestId === response.headers["x-request-id"]);
}
npx tsx src/check-errors.ts/missing 404 {"error":"Task 9 not found","code":"ERR_RESOURCE_NOT_FOUND"} true
/conflict 409 {"error":"A task called \"Buy milk\" already exists","code":"ERR_CONFLICT"} true
/invalid 400 {"error":"Validation failed","code":"ERR_SCHEMA_VALIDATION","issues":[{"path":"title","message":"String must be at least 3 characters"},{"path":"done","message":"Required field missing: done"}]} true
/json 400 {"error":"The request body is not valid JSON.","code":"BAD_REQUEST"} true
[report] ERR_INTERNAL_ERROR (Cannot read properties of undefined (reading 'title')) request=36 chars
/bug 500 {"error":"An unexpected error occurred.","code":"ERR_INTERNAL_ERROR"} trueEvery failure now has the same shape: a safe error message, a stable code, the requestId (the same value as the x-request-id header) and, for validation errors, the list of issues. The TypeError was the only one reported, and its text never reached the client. In the real server, logBugs writes that report as an error log line with the full error, stack trace included.
Check the types, restart npm run dev and send the Task API a bad task:
npx tsc --noEmit curl -i -X POST http://localhost:3000/tasks -H "content-type: application/json" -d '{"title":"no","priority":"urgent"}' HTTP/1.1 400 Bad Request x-request-id: 0c91c71f-5e2a-4cd2-aa68-1f112e36b595 content-type: application/json x-content-type-options: nosniff x-frame-options: DENY … content-length: 266 Date: Wed, 23 Sep 2026 20:01:50 GMT Connection: keep-alive Keep-Alive: timeout=5 {"error":"Validation failed","code":"ERR_SCHEMA_VALIDATION","requestId":"0c91c71f-5e2a-4cd2-aa68-1f112e36b595","issues":[{"path":"title","message":"String must be at least 3 characters"},{"path":"priority","message":"Expected one of \"low\", \"normal\", \"high\""}]}
A client, or the web app's form, can now show "String must be at least 3 characters" right next to the title field. The generated example resource still answers bad input itself, with validationFailed in examples.controller.ts: its bodies have error and issues, but no code and no requestId. Keep that in mind if you copy its shape for a new resource.
Practice
TRY IT YOURSELF
Complete a task
Add a complete(id) method to the TaskService: it throws TaskAlreadyDoneError when the task is already done, and NotFoundError when it does not exist. Try both through toErrorResponse-style handling: print the status and code of each outcome.
Show a solution
import { BaseError, ErrorCategory, normalizeToBaseError, NotFoundError } from "@zudojs/errors";
class TaskAlreadyDoneError extends BaseError {
constructor(taskId: number) {
super(`Task ${taskId} is already done`, {
code: "ERR_TASK_ALREADY_DONE",
category: ErrorCategory.BUSINESS,
statusCode: 409,
metadata: { taskId },
});
}
}
const tasks = new Map([[1, { id: 1, title: "Buy milk", done: false }]]);
function complete(id: number): { id: number; title: string; done: boolean } {
const task = tasks.get(id);
if (!task) throw new NotFoundError(`Task ${id} not found`);
if (task.done) throw new TaskAlreadyDoneError(id);
const updated = { ...task, done: true };
tasks.set(id, updated);
return updated;
}
for (const id of [1, 1, 5]) {
try {
console.log(200, complete(id));
} catch (thrown) {
const error = normalizeToBaseError(thrown);
console.log(error.statusCode, error.code, error.message);
}
}
npx tsx complete.ts and of the browser terminal200 { id: 1, title: 'Buy milk', done: true }
409 ERR_TASK_ALREADY_DONE Task 1 is already done
404 ERR_RESOURCE_NOT_FOUND Task 5 not foundTRY IT YOURSELF
What leaks?
A teammate writes this handler. List everything that can leak to a client, and say what to use instead.
function leakyHandler(error: unknown) {
return createResponseContext({ status: 500 }).json({
message: (error as Error).message,
stack: (error as Error).stack,
error,
});
}
Show a solution
messageof a bug or database error can contain SQL, table names, internal host names or file paths.stackshows your file layout, the libraries and versions you use, and the exact lines that failed: a map for an attacker.erroritself is serialized with itstoJSON(), which for aBaseErrorincludes the stack, the cause chain and the metadata.- The status is always 500, even for a client mistake such as a missing task, so clients cannot tell their mistakes from yours.
Use normalizeToBaseError for the status, serializePublicError for the message, and send the full details only to your logs, as toErrorResponse does.
Recap
- Expected failures (operational, 4xx) get a clear message; bugs (500) get a generic message for the client and full details in the logs.
- Every ZudoJS error, framework or application, extends
BaseErrorwithstatusCode,code,category,exposeandisOperational. Clients checkcode, notmessage. - Write your own errors by extending
BaseErrorwith a fixed code and status, and throw them from services. SchemaErrorandValidationErrorcarry issues;HttpErroris for HTTP-only problems.serializePublicErrorfor clients,toJSON()for logs,normalizeToBaseErrorfor anything thrown. Never send a stack trace.- In
@zudojs/http, one error-handling middleware just before the router catches every error a route throws, and gives it the same body with a request id.
That completes the ZudoJS core: a runtime, a container, routes, middleware, configuration, validation and errors. The next part replaces the in-memory store with a real database.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.