Palbase
Sign inGet started

Backend SDK

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.tsTodosController 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 writeThe 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 methodrefused 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 with return type `Todo` has no matching imported/exported zod schema in scope. The const + type pair 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 push bundles in your project directory with node_modules present, 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:

FieldTypeMeaning
errorstringthe machine-readable code (not_found, todo_locked, …) — what clients switch on
error_descriptionstringthe human-readable message
statusnumberthe HTTP status, repeated in the body
request_idstringthe 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 handler Object.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:

SituationerrorStatus
no route matches the method and pathnot_found404
a required route with no usable tokenunauthorized401
the token's role claim does not match the route'sforbidden403
the route declares verifiedEmail and the claim is not trueemail_not_verified403
a @Upload completion arrives unsignedunauthorized401
a per-route rate limit is exceededtoo_many_requests429
the handler returned a value its declared return type rejectsoutput_invalid500
anything thrown that is not an HttpErrorinternal_error500

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:

ClassStatusWire codeConstructor
BadRequest400bad_requestnew BadRequest({ fields }, message?)data-first
Unauthorized401unauthorizednew Unauthorized(message?, code?, data?)
Forbidden403forbiddennew Forbidden(message?, code?, data?)
NotFound404not_foundnew NotFound(message?, code?, data?)
Conflict409conflictnew Conflict(message?, code?, data?)
TooManyRequests429too_many_requestsnew 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") and new TooManyRequests() do not compile. Note also that a BadRequest you throw puts its fields under data, 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

RuleBehaviour
Call it at module top level with literal argumentsThe 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–599Anything else throws at definition time — an error response must not clobber a success response in the project's spec.
Codes are project-uniqueRe-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 schemaA 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 timeThe 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 codetodo_lockedexport 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") sends todo_not_found on the wire, but that code was never registered, so the spec skips it silently and clients fall through to their generic case. Use defineError for 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.