For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/reference/design-decisions/returned-errors.md.

Errors are returned

Context

An API spends much of its code on refusals: the resource does not exist, the user may not see it, the input conflicts with the data. These are not exceptional: they are expected outcomes of a request, as much as a success.

With exceptions as the only way out, the control flow of a handler is hidden: a throw deep in a service ends the request, and nothing in the handler shows it. HTTP frameworks also disagree on what happens next: Express 4 loses errors thrown by async handlers unless they are passed to next(), each framework has its own error handlers, and each app ends up with its own error format.

Decision

  • An error is a value. notFoundError(), forbiddenError() and the other helpers return an HttpError, and a handler or a middleware returns it: return notFoundError('Task not found').
  • Throwing gives the same response. An HttpError thrown by a handler, a middleware or a helper of the HTTP layer is answered exactly like a returned one.
  • Services never throw an HttpError. An HTTP error belongs to the HTTP layer. Services throw their own business errors, and the handler translates them into the response.
  • Anything else thrown is a bug. It is answered 500 INTERNAL_ERROR, logged, and passed to onError. Its details stay out of the response, unless exposeErrors is enabled.
  • One format for every error: { status, error, message, data? }, whether it comes from the handler, the validation, the router (404, 405) or the body parser.
handler: async ({ req, user }) => {
  const task = await tasks.find(req.params.id)
  if (!task) return notFoundError('Task not found')
  if (task.ownerId !== user.id) return forbiddenError()
  return task
}

Business errors and HTTP errors

A failed request can involve three different errors, and Dotpress keeps them apart:

ErrorExampleRaised byBecomes
Business errorthe task is already completed, the balance is insufficientthe service, as its own error classthe HTTP error the handler chooses
Process errorthe database is down, a timeout, a buganything500 INTERNAL_ERROR, logged, passed to onError
HTTP error409 CONFLICT, Task already completedthe handler, or a middlewarethe response the client receives

A service knows the rules of the domain, not HTTP. It signals an expected outcome by its return value (undefined when nothing is found), and a broken rule by a business error:

// services/tasks.ts: no HTTP here
export class 
class TaskAlreadyCompletedError
TaskAlreadyCompletedError
extends
var Error: ErrorConstructor
Error
{}
export const
const completeTask: (id: string) => Promise<Task | undefined>
completeTask
= async (
id: string
id
: string) => {
const
const task: Task | undefined
task
= await
const db: {
    tasks: {
        find(id: string): Promise<Task | undefined>;
        update(id: string, changes: Partial<Task>): Promise<Task>;
    };
}
db
.
tasks: {
    find(id: string): Promise<Task | undefined>;
    update(id: string, changes: Partial<Task>): Promise<Task>;
}
tasks
.
function find(id: string): Promise<Task | undefined>
find
(
id: string
id
)
if (!
const task: Task | undefined
task
) return
var undefined
undefined
if (
const task: Task
task
.
Task.completedAt: Date | null
completedAt
) throw new
constructor TaskAlreadyCompletedError(message?: string, options?: ErrorOptions): TaskAlreadyCompletedError (+1 overload)
TaskAlreadyCompletedError
(
id: string
id
)
return
const db: {
    tasks: {
        find(id: string): Promise<Task | undefined>;
        update(id: string, changes: Partial<Task>): Promise<Task>;
    };
}
db
.
tasks: {
    find(id: string): Promise<Task | undefined>;
    update(id: string, changes: Partial<Task>): Promise<Task>;
}
tasks
.
function update(id: string, changes: Partial<Task>): Promise<Task>
update
(
id: string
id
, {
completedAt?: Date | null | undefined
completedAt
: new
var Date: DateConstructor
new () => Date (+3 overloads)
Date
() })
} // routes/tasks.ts: the HTTP translation
defineRoute<"/tasks/:id/complete", EmptyObject, false, unknown[]>(route: RouteConfig<"/tasks/:id/complete", EmptyObject, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<P extends string, S extends RouteSchema, TParentParams = EmptyObject, TRawBody extends boolean = false, TProvided extends readonly unknown[] = unknown[], TGroupContext = unknown>.method?: HttpMethod | undefined

Defaults to get.

method
: 'post',
RouteConfig<"/tasks/:id/complete", EmptyObject, EmptyObject, false, unknown[], unknown>.path: "/tasks/:id/complete"
path
: '/tasks/:id/complete',
RouteConfig<"/tasks/:id/complete", EmptyObject, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<EmptyObject, {
    id: string;
}, false, unknown>
handler
: async ({
req: RouteRequest<EmptyObject, {
    id: string;
}, false>
req
}) => {
try { const
const task: Task | undefined
task
= await
const completeTask: (id: string) => Promise<Task | undefined>
completeTask
(
req: RouteRequest<EmptyObject, {
    id: string;
}, false>
req
.
DotpressRequest<{ id: string; }, QueryValues, unknown, UploadedFiles>.params: {
    id: string;
}
params
.
id: string
id
)
return
const task: Task | undefined
task
??
function notFoundError(message?: string, data?: ErrorData): HttpError
notFoundError
('Task not found')
} catch (
function (local var) error: unknown
error
) {
if (
function (local var) error: unknown
error
instanceof
class TaskAlreadyCompletedError
TaskAlreadyCompletedError
) return
function conflictError(message?: string, data?: ErrorData): HttpError
conflictError
('Task already completed')
throw
function (local var) error: unknown
error
// a process error: 500, reported by onError
} }, })

Returning the HTTP error is what makes the separation visible: the handler is the one place where a business error becomes a response, with a status, a stable code, and a message chosen to be shown to the client.

Alternatives considered

  • Exceptions only, with exception filters, as in NestJS. Familiar, but the exits of a handler are invisible, and a middleware cannot stop a request without throwing.
  • A Result type (ok(value) / err(error)), as in functional libraries. Explicit, but every call must be unwrapped, and it spreads to every service. Returning an HttpError gets the readability without the ceremony: a value is a success, an HttpError is a refusal.
  • The error handlers of the HTTP server. They differ from one server to another, and would tie the app to one of them.

Consequences

  • Handlers read as guard clauses: each refusal is an early return, visible where it happens.
  • A middleware stops a request the same way: by returning an error, or a response such as redirect().
  • Services stay free of HTTP: they run unchanged in a worker, a script or a test, and their errors describe the domain, not a status.
  • The client only sees messages chosen for it: the message of a business or process error never reaches the response by accident.
  • A business error the handler does not translate is a 500, reported by onError: a forgotten case is treated as the bug it is, without leaking anything.
  • The translation is written in the handlers: a try/catch, or a function shared by the routes of a feature. Dotpress has no global mapping from business errors to HTTP errors.
  • Clients parse one error format, and the error code is stable: they can rely on it rather than on messages.
  • The return type of a handler does not list its errors, and a returned error skips the response schema. Document the errors of a route in meta.responses, for OpenAPI.

See Errors, Errors of your services and the error reference.