Responses & Errors
In a Palbase backend you never build an HTTP response by hand. The success schema is your method's return type — read off the TypeScript at build time, so it must be a named zod schema that exists as a value — and the failure path is a thrown error class that the runtime catches wherever it surfaces, in a controller or six calls deep in a service. Errors you declare with defineError go one step further: they become typed error classes on your generated web and iOS clients, so throw new TodoLocked({ retryAfter: 30 }) on the server is a catchable TodoLocked with a typed retryAfter in the app.
Quick example
// modules/todos/dto/errors.ts
import { defineError, z } from "@palbase/backend";
export const TodoLocked = defineError(
"todo_locked",
409,
z.object({ retryAfter: z.number() }),
);
// modules/todos/todo.service.ts
// The decisions live here, and so do the throws.
import { Database, Injectable, NotFound } from "@palbase/backend";
import { Todo, UpdateTodo } from "./dto/todo";
import { TodoLocked } from "./dto/errors";
@Injectable()
export class TodoService {
async get(id: string): Promise<Todo> {
const todo = await Database.public.todos.findById(id);
if (!todo) throw new NotFound("todo not found");
return todo;
}
async update(id: string, patch: UpdateTodo): Promise<Todo> {
const todo = await this.get(id);
// A finished todo is locked against edits — a domain rule, so a domain error.
if (todo.done) throw new TodoLocked({ retryAfter: 30 });
const updated = await Database.public.todos.update({ where: { id }, set: patch });
if (!updated) throw new NotFound("todo not found");
return updated;
}
}
// modules/todos/todos.controller.ts
// HTTP only; the service is injected.
import { Controller, Get, Patch, Body, Param } from "@palbase/backend";
import { Todo, UpdateTodo } from "./dto/todo";
import { TodoService } from "./todo.service";
@Controller("/todos")
export class TodosController {
constructor(private readonly todos: TodoService) {}
@Get("/{id}")
get(@Param("id") id: string): Promise<Todo> {
return this.todos.get(id); // the return type IS the 200 schema
}
@Patch("/{id}")
update(@Param("id") id: string, @Body(UpdateTodo) body: UpdateTodo): Promise<Todo> {
return this.todos.update(id, body);
}
}
Both classes are named in modules/todos/todos.module.ts — TodosController under controllers, TodoService under providers — and a class no module lists is refused at build, by name.
The locked todo produces this response — and a typed TodoLocked on every generated client:
{
"error": "todo_locked",
"error_description": "Todo locked",
"status": 409,
"request_id": "req_…",
"data": { "retryAfter": 30 }
}
Response schemas come from return types
There is no response decorator. The 200 schema is derived from the method's named return type when the deploy stages your controllers:
// modules/todos/dto/todo.ts
// Declare it once, as a const AND a type with the same name.
import { z } from "@palbase/backend";
export const Todo = z.object({
id: z.string(),
title: z.string(),
done: z.boolean(),
});
export type Todo = z.infer<typeof Todo>;
export const UpdateTodo = z.object({ title: z.string().min(1).optional() });
export type UpdateTodo = z.infer<typeof UpdateTodo>;
| You write | The route gets |
|---|---|
: Promise<Todo> | 200 with the Todo shape |
: Promise<z.infer<typeof Todo>> | the same — the inline z.infer form resolves too |
: Promise<Todo[]> | 200 with an array of that shape |
: void / : Promise<void> | 204 No Content |
: Promise<{ id: string }> | refused at deploy — an inline type has no schema to point at |
| no return type at all, on any route method | refused at deploy — annotate it, : void included |
Warning: the named type must resolve to a zod schema that exists as a value. A bare
interface Todo { … }passes typecheck and fails the deploy withreturn type `Todo` has no matching imported/exported zod schema in scope. Theconst+typepair above is the pattern that works.
A handler that returns undefined or null answers 204 No Content with no body at all — which is why a : Promise<void> route has nothing to parse. When the handler returns a value, it is validated against the declared schema before it is sent; a value the schema rejects becomes output_invalid (below) rather than a wrong shape a client has to discover.
Note: always
import { z } from "@palbase/backend", never from"zod"directly. Not because third-party imports fail —palbase pushbundles in your project directory withnode_modulespresent, so an installed dependency does resolve and ship. It is because the stager matches your return type against the schemas the SDK's own zod instance produced; a second copy of zod in the graph is a schema the extractor cannot recognise as one.
The error envelope
Every failure answers with the same four keys, whether you threw it or the framework generated it:
| Field | Type | Meaning |
|---|---|---|
error | string | the machine-readable code (not_found, todo_locked, …) — what clients switch on |
error_description | string | the human-readable message |
status | number | the HTTP status, repeated in the body |
request_id | string | the per-request id, req_… — quote it when reporting a problem |
Beyond those four there are two different shapes, and mixing them up is the single most common mistake against this API:
A thrown error puts its payload under data.
{
"error": "todo_locked",
"error_description": "Todo locked",
"status": 409,
"request_id": "req_…",
"data": { "retryAfter": 30 }
}
A request refused at the boundary puts fields at the TOP LEVEL. When a @Body or @QueryParams schema rejects the input, nothing was ever thrown — your handler did not run — so there is no error payload and no data key:
{
"error": "bad_request",
"error_description": "Request body failed validation",
"status": 400,
"request_id": "req_…",
"fields": [{ "field": "title", "message": "String must contain at least 1 character(s)" }]
}
error_description is "Query parameters failed validation" for the query-string branch. field is the zod issue path joined with dots, so a nested failure reads "address.postcode". Both shapes are 400 and both use the code bad_request; a client tells them apart by looking for fields at the top level, not under data. Web clients read exactly this — see Web error handling.
Note:
@Headers(schema)is not validated at runtime. The schema is recorded for codegen, but the engine hands the handlerObject.fromEntries(req.headers)without parsing it, so a header that does not match produces no 400. Validate a header you depend on inside the method.
Codes the framework generates
These come from the runtime, not from your code, and none of them carry data:
| Situation | error | Status |
|---|---|---|
| no route matches the method and path | not_found | 404 |
| a required route with no usable token | unauthorized | 401 |
the token's role claim does not match the route's | forbidden | 403 |
the route declares verifiedEmail and the claim is not true | email_not_verified | 403 |
a @Upload completion arrives unsigned | unauthorized | 401 |
| a per-route rate limit is exceeded | too_many_requests | 429 |
| the handler returned a value its declared return type rejects | output_invalid | 500 |
anything thrown that is not an HttpError | internal_error | 500 |
The rate-limit refusal is the only one that also sets a header: retry-after, in seconds. A TooManyRequests you throw carries data.retryAfter in the body but does not set that header — set it yourself if a client depends on it. internal_error deliberately says only "The request could not be completed"; the real error goes to your logs, where you can read it with palbase logs.
Built-in error classes
Six named classes cover the common failure modes. Import them from @palbase/backend:
| Class | Status | Wire code | Constructor |
|---|---|---|---|
BadRequest | 400 | bad_request | new BadRequest({ fields }, message?) — data-first |
Unauthorized | 401 | unauthorized | new Unauthorized(message?, code?, data?) |
Forbidden | 403 | forbidden | new Forbidden(message?, code?, data?) |
NotFound | 404 | not_found | new NotFound(message?, code?, data?) |
Conflict | 409 | conflict | new Conflict(message?, code?, data?) |
TooManyRequests | 429 | too_many_requests | new TooManyRequests({ retryAfter }, message?) — data-first |
Message-first classes
Unauthorized, Forbidden, NotFound and Conflict take an optional message, an optional wire-code override, and an optional payload:
import { Conflict, NotFound } from "@palbase/backend";
declare const doneAt: string;
throw new NotFound(); // "Not found", code not_found
throw new NotFound("todo not found"); // custom message
throw new NotFound("no such todo", "todo_not_found"); // custom wire code
throw new Conflict("already done", "todo_done", { doneAt });
Omit the message and it defaults to a label derived from the class name — NotFound → "Not found", TooManyRequests → "Too many requests".
Data-first classes
BadRequest and TooManyRequests carry a fixed, typed payload and take it as the first argument, so generated clients see error.data.fields and error.data.retryAfter fully typed with no per-project declaration:
import { BadRequest, TooManyRequests } from "@palbase/backend";
throw new BadRequest({
fields: [{ field: "title", message: "must not be empty" }],
});
throw new TooManyRequests({ retryAfter: 30 }); // seconds
throw new TooManyRequests({ retryAfter: 30 }, "slow down");
interface FieldError { field: string; message: string }
interface BadRequestData { fields: FieldError[] }
interface TooManyRequestsData { retryAfter: number } // seconds
Warning: the payload comes before the optional message on these two.
new BadRequest("missing field")andnew TooManyRequests()do not compile. Note also that aBadRequestyou throw puts itsfieldsunderdata, unlike the boundary refusal above, which puts them at the top level — the two are different events and the wire says so.
Typed project errors with defineError
For your domain's own failure modes, declare the class once and throw it anywhere:
defineError has two overloads. Without a data schema it produces a class whose constructor takes an optional message; with one, the payload comes first and the message second.
// modules/todos/dto/errors.ts
import { defineError, z } from "@palbase/backend";
// No payload: new TodoArchived(message?)
export const TodoArchived = defineError("todo_archived", 410);
// Typed payload: new TodoLocked(data, message?)
export const TodoLocked = defineError(
"todo_locked",
409,
z.object({ retryAfter: z.number() }),
);
// modules/todos/archive.service.ts
// Throwing from a service is the normal case.
import { Injectable } from "@palbase/backend";
import { TodoLocked } from "./dto/errors";
@Injectable()
export class ArchiveService {
archive(): void {
throw new TodoLocked({ retryAfter: 30 });
}
}
Each class exposes its identity statically — TodoLocked.code === "todo_locked", TodoLocked.status === 409 — and defaults its message to the humanized code ("todo_locked" → "Todo locked").
Rules
| Rule | Behaviour |
|---|---|
| Call it at module top level with literal arguments | The deploy's static analysis reads the string and number literals to work out which routes can throw what. A defineError built from variables, or inside a function, is invisible to it. |
status must be an integer 400–599 | Anything else throws at definition time — an error response must not clobber a success response in the project's spec. |
| Codes are project-unique | Re-registering an identical definition of your own code is idempotent (a shared dto/errors.ts is imported by several modules, so the same definition is evaluated more than once). The same code with a different status or a different data schema is a hard failure — and so is re-declaring one of the pre-seeded built-ins, whatever status and shape you give it. |
dataSchema must be a plain zod schema | A schema carrying .openapi(refId) metadata is rejected where it is defined — it would emit a $ref into components the project spec never writes. |
| The payload is validated at throw time | The constructor runs schema.parse(data), so a mismatched payload fails at the throw site rather than on the wire. |
| Name the exported const after the code | todo_locked → export const TodoLocked. The class name in logs and in generated clients is derived from the code the same way. |
The registry is pre-seeded with nine codes, not six: the six built-ins above plus entitlement_required (403), quota_exceeded (429) and credit_insufficient (429), which belong to the purchases decorators. Re-declaring one of those nine yourself is the hard failure described above, and matching the built-in is not the way out: the idempotent path is open only to codes your own project defined, so defineError("conflict", 409) — same status, no data schema — still throws duplicate error code "conflict" with a different shape (existing: status 409, built-in). Pick another code.
Typed errors on generated clients
The errors a route can throw are resolved from the method body and the services it calls, and travel into your generated clients: a typed class with a typed data payload in the browser, per-endpoint failure enums on iOS.
Warning: a code override on a built-in does not get that treatment.
throw new NotFound("no such todo", "todo_not_found")sendstodo_not_foundon the wire, but that code was never registered, so the spec skips it silently and clients fall through to their generic case. UsedefineErrorfor anything a client should branch on.
Escape hatch: HttpError and PalError
When none of the named classes fits and the failure is not worth a reusable type:
import { HttpError, PalError } from "@palbase/backend";
throw new PalError(418, "teapot", "I'm a teapot");
throw new HttpError(503, "upstream_down", "Payment provider unavailable", { provider: "stripe" });
declare class HttpError extends Error {
constructor(status: number, error: string, errorDescription: string, data?: unknown);
}
declare class PalError extends HttpError {
constructor(status: number, code: string, description: string, data?: unknown);
}
HttpError is the base of everything throwable here — the built-ins, defineError classes and PalError all extend it. The runtime recognises it by a brand (Symbol.for("palbase.backend.httpError")), never by instanceof: class identity does not survive two copies of the SDK in one graph, and when it did not, a route throwing NotFound answered 500 while the runtime's own log printed status: 404 beside it. The brand check is internal — isHttpError is not a public export, so code of yours that must discriminate should read err.status and err.error.
Related
- Request Validation — the
@Body/@QueryParamsschemas that produce the boundary 400 - Authentication — where the 401 and the two 403s come from
- Controllers & Routing — route options, including the rate limit behind
too_many_requests - Testing — asserting on both 400 shapes from a deploy's own test run
- Web error handling · iOS error handling