Skip to content

Controllers

A controller in Warlock is a thin function. It pulls inputs from request, calls exactly one piece of work, and returns through a response.<helper>(). No class to extend, no decorators, no DI container.

The thin-controller rule is the most load-bearing convention in the framework. Once a controller starts owning logic, the module gets harder to test, harder to reuse from a queue worker, and harder to read. This page covers the shape, the reading surface, the returning surface, and how validation hooks in.

src/app/products/controllers/list-products.controller.ts
import type { RequestHandler } from "@warlock.js/core";
export const listProductsController: RequestHandler = async ({ request, response }) => {
// …pull inputs, call work, return
return response.success({ products: [] });
};

That’s the contract:

  • RequestHandler is the function type.
  • request and response are properties of the handler’s single context object.
  • The RequestHandler annotation contextually types both properties, so editor completions surface every request method and response helper.

The export name follows <action>Controller in camelCase: listProductsController, createProductController, getProductController, removeProductController.

One controller per file, under the module’s controllers/ folder:

src/app/products/controllers/
list-products.controller.ts
get-product.controller.ts
create-product.controller.ts
update-product.controller.ts
remove-product.controller.ts

Scaffold with the CLI:

Terminal window
pnpm warlock generate.controller products/list-products
# add --with-validation to scaffold the schema + request type alongside
pnpm warlock generate.controller products/create-product --with-validation

The Request object exposes everything you need to pull data off the wire. The everyday helpers:

MethodReturnsUse when
request.input("key", default?)one field from query, params, or bodyreading a single param/body field
request.all()full merged input map (query + body + params)passing the whole thing to a service
request.validated()schema-typed object (only when schema attached)controllers with validation — always preferred over .all()
request.locals.userauthenticated user (after authMiddleware)guarded routes
request.file("key")UploadedFile instancemultipart uploads (single file)
request.files("key")UploadedFile[]multipart uploads (many files)
request.header("X-Foo")header valuereading request metadata
request.ip, request.realIpclient IP (proxy-aware via realIp)logging, rate limiting, geolocation
request.userAgentuser-agent stringdevice-info capture
request.locale, t(...)locale code + translator (t from core)localized responses

There are also typed shortcuts: request.int("id"), request.bool("active"), request.float("price"), request.string("name"), request.email(). Use them when you want a coerced value without writing the conversion yourself.

If the controller has a schema attached, always prefer request.validated(). It returns the schema-typed object, so TypeScript knows the shape and your editor autocompletes the fields:

import type { Request, RequestHandler } from "@warlock.js/core";
import { type CreateProductSchema } from "../schema/create-product.schema";
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
request,
response,
}) => {
const data = request.validated();
// data.name, data.price are typed
};

request.all() returns any and skips validation entirely. Reserve it for routes that truly take untyped input (admin debug endpoints, webhook receivers where the schema is the contract).

The Response object exposes a helper per HTTP outcome. The helper carries the status code, so you almost never set one by hand. Pick the helper that matches the outcome:

HelperStatusWhen
response.success(data)200normal read / update
response.successCreate(data)201resource created (POST)
response.accepted(data?)202async work accepted but not yet done
response.noContent()204delete succeeded, no body needed
response.badRequest(data)400malformed or invalid input
response.unauthorized(data?)401missing/invalid token
response.forbidden(data?)403authenticated but not allowed
response.notFound(data?)404record missing
response.conflict(data?)409uniqueness violation, state conflict
response.unprocessableEntity(data)422semantic validation error

Always return response.<helper>(...). The return value drives the framework’s send pipeline.

return response.success({ products, pagination });
return response.successCreate({ product });
return response.notFound({ error: t("product.notFound") });
return response.noContent();

For redirects, files, streams, and Server-Sent Events, see HTTP response.

Validation in Warlock is two pieces: the schema (from @warlock.js/seal) and a property on the handler function.

src/app/products/schema/create-product.schema.ts
import { v, type Infer } from "@warlock.js/seal";
export const createProductSchema = v.object({
name: v.string(),
price: v.number().min(0),
category_id: v.string(),
});
export type CreateProductSchema = Infer<typeof createProductSchema>;

Infer<typeof createProductSchema> produces the static type from the runtime schema. The schema file exports both — value (createProductSchema) and type (CreateProductSchema) — from one place. No separate *.request.ts alias file.

src/app/products/controllers/create-product.controller.ts
import type { Request, RequestHandler } from "@warlock.js/core";
import { type CreateProductSchema, createProductSchema } from "../schema/create-product.schema";
import { createProductService } from "../services/create-product.service";
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ({
request,
response,
}) => {
const product = await createProductService(request.validated());
return response.successCreate({ product });
};
createProductController.validation = {
schema: createProductSchema,
};

The controller’s RequestHandler<Request<CreateProductSchema>> annotation types request.validated() straight off the schema. For routes behind authMiddleware, use GuardedRequestHandler<CreateProductSchema> (from app/auth/types/guarded-request.type) so request.locals.user is also typed.

createProductController.validation = { schema } is the wiring. The framework reads the property when registering the route. If validation fails, the framework returns a 400 with an errors payload and your handler never runs:

{
"errors": [
{ "input": "name", "error": "Name is required" },
{ "input": "price", "error": "Price must be at least 0" }
]
}

By default, the schema runs against the merged body + query (params are excluded — the route already validated them by matching the path). To include params or headers:

createProductController.validation = {
schema: createProductSchema,
validating: ["body", "query", "params"],
};

Add params next to schema to validate the route’s path segments. Route params arrive as strings, so number and boolean validators convert them for you:

src/app/posts/controllers/get-post.controller.ts
import { v } from "@warlock.js/seal";
import { type RequestHandler } from "@warlock.js/core";
// router.get("/posts/:id", getPost)
export const getPost: RequestHandler = async ({ request, response }) => {
const id = request.input("id"); // 42, not "42"
return response.success({ post: await getPostService(id) });
};
getPost.validation = {
params: v.object({ id: v.int().min(1) }),
};
  • Route params are validated against the params schema on every request.
  • v.int(), v.number() and v.boolean() convert the string segments automatically. Your schema object is never changed; core converts a copy of it.
  • The parsed values replace request.params, so request.input("id") returns a number.
  • They also merge into request.validated(). When a param and a body field share a name, the param wins.
  • A failure answers exactly like a schema failure (the same 422 errors response by default), and the handler never runs.
  • Routes without params still receive raw string params.

Here’s the actual login controller from the reference codebase. Three input sources, one use-case, two response paths:

src/app/auth/controllers/login.controller.ts
import { t, type Request, type RequestHandler } from "@warlock.js/core";
import { type LoginSchema, loginSchema } from "../schema/login.schema";
import { loginUseCase } from "../use-cases/login.usecase";
export const login: RequestHandler<Request<LoginSchema>> = async ({ request, response }) => {
const result = await loginUseCase({
data: request.validated(),
deviceInfo: {
userAgent: request.userAgent,
ip: request.ip,
},
});
if (!result) {
return response.unauthorized({
error: t("auth.invalidCredentials"),
});
}
return response.success(result);
};
login.description = "User Login";
login.validation = {
schema: loginSchema,
};

Read it top-to-bottom:

  1. The controller is typed RequestHandler<Request<LoginSchema>> — schema and type come from one schema file. The schema is attached at the bottom.
  2. It calls loginUseCase with the validated credentials + device metadata from the headers.
  3. If the use-case returns nothing, the controller maps that to a 401 with a translated error.
  4. Otherwise it sends the result through response.success(...).
  5. The .description property feeds documentation generation.

Twenty-five lines, no business logic. The credential check, token issuance, and session creation all live in loginUseCase and its service — the controller is just the HTTP shell.

Three properties you can attach to a handler:

createProductController.validation = { schema: createProductSchema };
createProductController.description = "Create a new product (admin only)";
createProductController.responseSchema = {
201: { body: { product: ProductResource } },
400: { body: { error: "string" } },
};
  • validation — what’s covered above.
  • description — surfaces in dev-server logs and in the generated OpenAPI document.
  • responseSchema — declares the response body per status code. It feeds the OpenAPI document and the typed client, see below.

Declare response types with responseSchema

Section titled “Declare response types with responseSchema”

responseSchema is keyed by HTTP status code; each entry has a body object. The values of a body are one of:

ValueMeaning
a cast string, with the resource suffixes"string", "number?", "string[]", "date[]?". ? means the key is always present and the value may be null.
a resourceOne object shaped like the resource’s output.
[Resource]An array of that resource.
a nested plain objectRecursed, with any of the values above.
src/app/auth/controllers/login.controller.ts
import { UserResource } from "app/users/resources/user.resource";
login.responseSchema = {
200: { body: { user: UserResource, token: "string" } },
400: { body: { error: "string" } },
};

The declared types follow the same rules as a resource’s typed output: ResponseBodyOutput<B> gives the type of one body and ResponseSchemaOutput<S> the body per status code for a whole schema. You rarely need either, because the types are generated:

  • warlock dev and warlock generate.typings load your routes, read each responseSchema and hand every named route’s response types to the web route-types generator. A route’s generated entry in web’s ApiRouteRegistry then carries a response map, which types useSubmitForm({ route }) and the ApiResponse helpers on the client. See Typed API responses.
  • A resource is mapped back to its *.resource.ts(x) export by identity. A resource defined inline, or outside such a file, is typed unknown and a warning names the route and field.
  • responseSchema is kept when a route is bound from an array handler ([controller, "action"]) or a restful resource method.
  • The same declaration fills the responses of warlock generate.openapi. Nothing checks a response against the schema at runtime: it is for types and documentation.

What belongs in a controller (and what doesn’t)

Section titled “What belongs in a controller (and what doesn’t)”

Belongs:

  • Input pulling (request.validated() / request.input(...))
  • Calling exactly one service or use-case
  • HTTP-shaped error branching (if (!product) return response.notFound(...))
  • Returning via response.<helper>()

Doesn’t belong:

  • Database queries → push to a repository, called by a service
  • Transactions → use-cases own them
  • External API calls → services
  • Cross-cutting orchestration of multiple services → use a use-case
  • Logging beyond what the framework does automatically

A controller that’s longer than ~30 lines usually has work hiding inside it. Extract a service.

  • Don’t throw for HTTP-shaped errors. Use response.<helper>(). Throwing escalates to the framework’s 500 handler unless you throw an HttpError subclass (BadRequestError, ForbiddenError, ResourceNotFoundError, …) — and even then, response.<helper>() reads more naturally.
  • Don’t read request.body directly. Use request.all() / request.validated() — they handle multipart, JSON, and form bodies uniformly.
  • Don’t omit the RequestHandler annotation. It contextually types the single { request, response } context object, including every response helper.
  • The validation property is on the handler, not the route. A common slip-up is setting it on the result of router.get(...). The property goes on the controller function itself.
  • Use-cases — the structured pipeline a controller hands off to.
  • Routing — how the controller gets wired to a URL.
  • HTTP request — the full Request surface (cookies, files, headers, locale).
  • HTTP response — the full Response surface (redirects, files, streams, SSE).
  • Validation — schema patterns, custom validators, conditional rules.

Continue to Use-cases to see the structured pipeline the controller calls into.