Palbase
Sign inGet started

Backend SDK

Request Validation

Request input is declared with parameter decorators carrying zod schemas. The engine validates before your method runs, so by the time your code executes body and query already match the schema and a request that did not is already refused with a 400 naming the failing fields. The same schemas drive the generated client types, so the contract holds end to end. Two things this page is blunt about, because both have caught people: headers are not validated even when you pass a schema, and the request body is not where an upload arrives.

Quick example

// modules/todos/dto/create.ts
import { z } from "@palbase/backend";

export const CreateTodoBody = z.object({
  title: z.string().min(1).max(200),
  dueDate: z.string().datetime().optional(),
});
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
// modules/todos/dto/list.ts
import { z } from "@palbase/backend";

export const ListQuery = z.object({
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type ListQuery = z.infer<typeof ListQuery>;
// modules/todos/todos.controller.ts
import { Body, Controller, Get, Param, Post, QueryParams, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

import { CreateTodoBody } from "./dto/create";
import { ListQuery } from "./dto/list";
import { TodoSchema } from "./dto/shared";
import { TodoService } from "./todo.service";

@Controller("/todos")
export class TodosController {
  // The container supplies it; `modules/todos/todos.module.ts` lists both.
  constructor(private readonly todos: TodoService) {}

  @Post("")
  create(@User() user: UserT, @Body(CreateTodoBody) body: CreateTodoBody): Promise<TodoSchema> {
    // body.title is a validated, typed string here
    return this.todos.create(user.id, body);
  }

  @Get("")
  list(@QueryParams(ListQuery) query: ListQuery): Promise<TodoSchema[]> {
    return this.todos.list(query);
  }

  @Get("/{id}")
  get(@Param("id") id: string): Promise<TodoSchema> {
    return this.todos.get(id);
  }
}

The service the controller names is an ordinary @Injectable() class, and it is the layer that touches Database — the controller never does. modules/todos/todos.module.ts lists both:

// modules/todos/todo.service.ts
import { Database, Injectable, NotFound } from "@palbase/backend";

import { CreateTodoBody } from "./dto/create";
import { ListQuery } from "./dto/list";
import { SearchTodosBody } from "./dto/search";
import { TodoSchema } from "./dto/shared";
import { UpdateTodoBody } from "./dto/update";

@Injectable()
export class TodoService {
  /** Ownership is written HERE — the request body may not carry it. */
  create(userId: string, input: CreateTodoBody): Promise<TodoSchema> {
    return Database.public.todos.insert({ user_id: userId, title: input.title });
  }

  list(query: ListQuery): Promise<TodoSchema[]> {
    return Database.public.todos.findMany({ limit: query.limit });
  }

  async get(id: string): Promise<TodoSchema> {
    const row = await Database.public.todos.findById(id);
    if (!row) throw new NotFound("no todo with that id");
    return row;
  }

  async update(id: string, body: UpdateTodoBody): Promise<TodoSchema> {
    const row = await Database.public.todos.update({ where: { id }, set: body });
    if (!row) throw new NotFound("no todo with that id");
    return row;
  }

  search(body: SearchTodosBody): Promise<TodoSchema[]> {
    return Database.public.todos.findMany({ where: { done: body.done ?? false } });
  }
}

Warning: Always import { z } from "@palbase/backend" — never from "zod" directly. The package re-exports zod so that the schema you build and the SDK that inspects it are the same zod instance. Two copies of zod in one process produce schemas the extractor does not recognise.

The parameter decorators

Four decorators carry request input:

DecoratorSourceSchemaValidated at runtime?
@Body(schema)JSON request bodyrequiredyes
@QueryParams(schema)query stringrequiredyes
@Param(name)one {name} path segmentno — always a string
@Headers(schema?)request headersoptionalno — see below

Seven more inject something other than input:

DecoratorInjects
@User()the caller: { id, email, emailVerified, role, roles, metadata, device }roles is read from the database per request, the rest from the verified token
@OptionalUser()the same object, or null when the request carried no valid token
@RequestId()the request id (req_…)
@TraceId()the same value as @RequestId() today — not a W3C trace id
@Req()the raw Web Request
@UploadedObject()the object storage accepted, on an @Upload route
@Client()nothing — the engine has no case for it and injects undefined

The type annotation you write next to a decorator is for autocomplete; validation always comes from the schema. The convention is to export the zod value and a same-named inferred type from the DTO file, so @Body(CreateTodoBody) body: CreateTodoBody reads naturally.

Warning: @Client() is exported but not implemented. A parameter decorated with it receives undefined in development and in production alike, with nothing reported. Do not use it.

Note: @Req() gives you the raw Web Requestreq.headers, req.url, req.method, await req.text(). It is not a Palbase wrapper: there is no req.file, no req.user and no req.input. The PBRequest type is a legacy shape the engine never constructs.

@Body

For POST/PUT/PATCH/QUERY routes with a JSON body. The schema validates the parsed body and becomes the request-body schema in the generated clients:

// modules/todos/dto/update.ts
import { z } from "@palbase/backend";

export const UpdateTodoBody = z.object({
  title: z.string().min(1).max(200).optional(),
  done: z.boolean().optional(),
});
export type UpdateTodoBody = z.infer<typeof UpdateTodoBody>;
// modules/todos/todo-update.controller.ts
import { Body, Controller, Param, Patch } from "@palbase/backend";

import { TodoSchema } from "./dto/shared";
import { UpdateTodoBody } from "./dto/update";
import { TodoService } from "./todo.service";

// One more class the module's `controllers` list names — nothing else registers it.
@Controller("/todos")
export class TodoUpdateController {
  constructor(private readonly todos: TodoService) {}

  @Patch("/{id}")
  update(
    @Param("id") id: string,
    @Body(UpdateTodoBody) body: UpdateTodoBody,
  ): Promise<TodoSchema> {
    return this.todos.update(id, body);
  }
}

The injected value is the parsed result, so zod .default(...) values and .transform(...) outputs are applied before your method runs.

A body that is absent or is not valid JSON is not a special case: the engine parses it as {} and hands that to your schema, so the schema decides. z.object({ title: z.string() }) rejects it with the usual 400; a schema where every key is optional accepts it.

@QueryParams

Query-string parameters arrive as strings on the wire — the schema owns any coercion. Use z.coerce.number() so ?limit=20 validates into a real number:

// modules/todos/dto/list-coerced.ts
import { z } from "@palbase/backend";

export const ListQuery = z.object({
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type ListQuery = z.infer<typeof ListQuery>;

The query string is flattened into a plain object before the schema sees it, so a repeated key does not become an array: ?tag=a&tag=b reaches your schema as { tag: "b" }. Model multi-valued input as one delimited string you split yourself, or move the route to @Query and put the list in the body.

Warning: Avoid z.coerce.boolean() for query flags — zod coerces with Boolean(value), so the string "false" becomes true. Use an explicit mapping instead: z.enum(["true", "false"]).transform((v) => v === "true").optional().

Input for QUERY routes

A route declared with the @Query method decorator (HTTP QUERY, RFC 10008 — a safe, idempotent read that carries a request body) takes its input through @Body, not @QueryParams:

// modules/todos/dto/search.ts
import { z } from "@palbase/backend";

export const SearchTodosBody = z.object({
  tags: z.array(z.string()).max(20),
  done: z.boolean().optional(),
});
export type SearchTodosBody = z.infer<typeof SearchTodosBody>;
// modules/todos/todo-search.controller.ts
import { Body, Controller, Query } from "@palbase/backend";

import { SearchTodosBody } from "./dto/search";
import { TodoSchema } from "./dto/shared";
import { TodoService } from "./todo.service";

@Controller("/todos")
export class TodoSearchController {
  constructor(private readonly todos: TodoService) {}

  @Query("/search")
  search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
    return this.todos.search(body);
  }
}

Warning: Never combine @Body and @QueryParams on a QUERY route — the web codegen drops operations that declare both. Put the whole input in the body schema.

See Controllers & Routing for when to choose QUERY over GET or POST. If you write @Query(SomeSchema) on a parameter by mistake, the decorator throws at decoration time and names the replacement: the query-string parameter decorator has been @QueryParams(schema) since @palbase/backend 9.0.0.

@Headers — recorded, not enforced

@Headers() injects the request headers as a plain object with lowercase keys. Passing a schema records it for the generated API spec, so the header shows up as a documented parameter on your clients — but the engine never parses it. A request whose headers violate the schema reaches your handler untouched:

// modules/billing/billing.controller.ts
import { Body, Controller, Headers, Post, z } from "@palbase/backend";

export const IdempotencyHeaders = z.object({
  "x-idempotency-key": z.string().uuid(),
});
export type IdempotencyHeaders = z.infer<typeof IdempotencyHeaders>;

export const ChargeBody = z.object({ amount_minor: z.number().int().positive() });
export type ChargeBody = z.infer<typeof ChargeBody>;

export const ChargeResult = z.object({ id: z.string(), captured: z.boolean() });
export type ChargeResult = z.infer<typeof ChargeResult>;

@Controller("/billing")
export class BillingController {
  @Post("/charge")
  charge(
    @Body(ChargeBody) body: ChargeBody,
    @Headers(IdempotencyHeaders) headers: IdempotencyHeaders,
  ): Promise<ChargeResult> {
    // The engine did NOT check this — parse it yourself or it is not checked at all.
    const key = IdempotencyHeaders.parse(headers)["x-idempotency-key"];
    return Promise.resolve({ id: key, captured: body.amount_minor > 0 });
  }
}

If the header genuinely has to be well-formed, parse it in the handler as above, or throw BadRequest yourself. Do not assume the annotation is a gate.

@Param

Injects a single path parameter by name. Declare the segment in the route path with {} and match the name exactly:

// modules/comments/comments.controller.ts
import { Controller, Get, Param, z } from "@palbase/backend";

export const CommentSchema = z.object({ id: z.string(), body: z.string() });
export type CommentSchema = z.infer<typeof CommentSchema>;

@Controller("/todos")
export class CommentsController {
  @Get("/{id}/comments/{commentId}")
  getComment(
    @Param("id") id: string,
    @Param("commentId") commentId: string,
  ): Promise<CommentSchema> {
    return Promise.resolve({ id: commentId, body: `a comment on ${id}` });
  }
}

Values are URL-decoded and are always strings — convert in your service when you need more. A @Param("name") whose name is not a segment in the route path injects undefined, silently, so keep the two spellings in step.

Where schemas live

Schemas live with the domain that owns them, in the module's own dto/ folder — one file per endpoint, plus a shared.ts for shapes reused across endpoints:

modules/
└── todos/
    ├── todos.module.ts       # lists the controller and the service
    ├── todos.controller.ts
    ├── todo.service.ts
    └── dto/
        ├── shared.ts         # TodoSchema — the response shape, reused everywhere
        ├── create.ts         # CreateTodoBody
        └── update.ts         # UpdateTodoBody

Nothing discovers dto/ — it is an ordinary import path, so the layout inside it is yours. What is not optional is the double export below.

Each file exports the zod value and a same-named type:

// modules/todos/dto/shared.ts
import { z } from "@palbase/backend";

export const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  done: z.boolean(),
});
export type TodoSchema = z.infer<typeof TodoSchema>;

Response shapes follow the same convention, because the method's return type names the response schema and it must resolve to a zod value in scope — a bare interface is rejected by the deploy. See Responses & Errors.

What a validation failure looks like

When @Body or @QueryParams rejects, the route answers 400 with the code bad_request and a top-level fields array:

{
  "error": "bad_request",
  "error_description": "Request body failed validation",
  "status": 400,
  "request_id": "req_01h…",
  "fields": [
    { "field": "title", "message": "String must contain at least 1 character(s)" },
    { "field": "dueDate", "message": "Invalid datetime" }
  ]
}
  • field is the dotted zod path into the rejected value — address.city for a nested object, items.0.qty for an array element.
  • message is zod's own message for that issue.
  • There are exactly two descriptions: Request body failed validation and Query parameters failed validation. No other source is validated, so no other description exists — there is no header failure and no path-parameter failure.

Two 400s, two shapes

This is the distinction to internalise, because the payload sits in a different place in each:

CauseerrorWhere the detail is
A boundary schema rejected the requestbad_requestfields, at the top level of the envelope
Your code threw an HttpErrorthe error's own codedata, the payload the error was constructed with

So a thrown BadRequest — the right tool for input that is well-formed but wrong — puts the same field list one level deeper:

import { BadRequest } from "@palbase/backend";

throw new BadRequest({ fields: [{ field: "dueDate", message: "must be in the future" }] });
{
  "error": "bad_request",
  "error_description": "Bad request",
  "status": 400,
  "request_id": "req_01h…",
  "data": { "fields": [{ "field": "dueDate", "message": "must be in the future" }] }
}

BadRequest's first argument is the data, not a message: new BadRequest({ fields: [...] }, "optional message"). See Responses & Errors for the full envelope and for defineError. Generated clients surface both shapes as typed errors — Errors on web, Errors on iOS.

Note: .refine(), .transform() and .preprocess() run at request time like any other zod logic, but the generated client types are based on the schema's underlying base type — a z.string().refine(isSlug) is a plain string to clients. Keep refinements for validation, not for reshaping the wire type.

The response is validated too

When a route declares a return type, the engine parses the returned value against that schema before answering. A value the declared type rejects is not sent: the route answers 500 with the code output_invalid and the issues are logged.

{
  "error": "output_invalid",
  "error_description": "The handler returned a value its declared return type rejects",
  "status": 500,
  "request_id": "req_01h…"
}

Warning: Output validation runs after the request's database transaction has committed. An output_invalid therefore means the write happened and the caller was told the request failed. Return exactly what your schema declares — most often this fires because a column is null where the schema says string, or because a row was returned whole when the schema names a subset.

A handler that returns undefined or null answers 204 No Content with no body.

Uploads are not a request body

Files do not arrive through @Body, and there is no multipart parsing in your handler. An upload is declared with @Upload, the bytes go straight to storage, and your method body runs afterwards as the completion handler:

import { Controller, Upload, UploadedObject, z } from "@palbase/backend";

export const Attachment = z.object({ path: z.string(), width: z.number().optional() });
export type Attachment = z.infer<typeof Attachment>;

@Controller("/attachments")
export class AttachmentsController {
  @Upload("", { bucket: "posts", pathTemplate: "{userId}/{uploadId}-{filename}" })
  async store(@UploadedObject() object: UploadedObject): Promise<Attachment> {
    return { path: object.path, width: object.width };
  }
}

UploadedObject is one export carrying both the decorator and the type, so a single import serves both positions. The client never chooses the storage path, size and MIME limits are the bucket's, and the completion is idempotent by upload id — a retried completion replays the first answer rather than running your handler twice. See Direct Uploads for the whole flow, and Uploads on web and Uploads on iOS for the client half.