Errors

Contract Kit has two complementary error surfaces:

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.