Errors
Contract Kit has two complementary error surfaces:
- Server-side
AppErrorfor structured application failures - Client-side
ContractErrorfor failed HTTP calls, validation failures, malformed responses, and network failures
Error shape
Framework-owned errors use a standard envelope:
{
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"issues": [
{ "path": ["title"], "message": "Required" }
]
}
}
Some framework-owned errors may also include a top-level requestId when your server context exposes one. Route-owned error responses can use any schema declared in contract.responses. Using { code, message, details? } for route-owned errors keeps your application errors consistent with framework errors.
Define an error catalog
import { createErrorFactory, defineErrors } from "@contract-kit/errors";
import { z } from "zod";
const TodoNotFoundResponse = z.object({
code: z.literal("TODO_NOT_FOUND"),
message: z.string(),
details: z.object({ id: z.string() }),
});
export const errors = defineErrors({
TodoNotFound: {
code: "TODO_NOT_FOUND",
status: 404,
message: "Todo not found",
responseSchema: TodoNotFoundResponse,
},
Unauthorized: {
code: "UNAUTHORIZED",
status: 401,
message: "You must be signed in",
},
});
export const err = createErrorFactory(errors);
The optional responseSchema field accepts any Standard Schema-compatible validator. Use it to keep route-owned error schemas next to the catalog entry, then reference the schema from contracts:
export const getTodo = todos.get("/api/todos/:id").responses({
200: TodoSchema,
404: errors.TodoNotFound.responseSchema,
});
Throw AppError
Throw AppError from handlers, use cases, or domain/application code when you want a typed HTTP failure.
export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
const todo = await ctx.ports.db.todos.findById(path.id);
if (!todo) {
throw err.appError("TodoNotFound", { details: { id: path.id } });
}
return { status: 200, body: todo };
});
AppError instances thrown by a route handler are route-owned. If the route declares response schemas, the generated response must match the schema for that status.
Preserve causes
Use cause for debugging without exposing internal errors to clients.
import { AppError, httpErrors } from "@contract-kit/errors";
try {
await db.query(...);
} catch (dbError) {
throw new AppError(
httpErrors.InternalServerError,
{ table: "todos" },
"Database query failed",
{ cause: dbError },
);
}
Handle client errors
call() throws ContractError for non-2xx responses and local client failures.
import { isContractError } from "@contract-kit/client";
try {
await getTodoEndpoint.call({ path: { id: "missing" } });
} catch (error) {
if (isContractError(error, 404)) {
console.log(error.body.code);
} else if (isContractError(error) && error.hasCode("VALIDATION_ERROR")) {
console.log(error.details);
}
}
For declared route-owned error responses, error.body is the parsed response body. For framework-owned errors, error.body uses the standard envelope when applicable. error.details is the nested details value from that envelope or local validation details.
Use safeCall
Use safeCall() when explicit result handling reads better than exceptions.
const result = await getTodoEndpoint.safeCall({ path: { id: "missing" } });
if (result.ok) {
console.log(result.data);
} else if (result.status === 404) {
console.log(result.error.body);
}
React Query integration uses call() because TanStack Query already models failures through its error channel.