Validation
In Primate, validation refers to making sure that input passed into your routes — typically from the frontend or API clients — is properly checked at runtime. This ensures your backend logic never executes on malformed or malicious data.
Because the web is for the most part untyped, everything arrives as strings, binary blobs, or loosely structured JSON. Primate uses its own validation framework, Pema, to define schemas for these inputs. These schemas are applied in your routes to guarantee that inputs match the shapes your logic expects.
This page covers where schemas integrate with Primate. See the Pema reference for schema construction, individual types, composition and standalone parsing.
Validation errors are surfaced as ParseError. Unlike regular errors,
Primate automatically serializes them into a 400 Bad Request JSON response
and returns them to the client:
{
"/email": {
"type": "invalid_format",
"message": "Expected valid email",
"messages": ["Expected valid email"]
}
}You can override this by catching the error yourself and returning a custom response.
Summary
| Input type | In Primate | Types | Use cases |
|---|---|---|---|
| Web forms | request.body.form |
string|File |
form submission, Authentication |
| JSON API calls | request.body.json |
JSONValue |
REST APIs, client-side fetch calls |
| Binary uploads | request.body.blob |
Blob |
File uploads |
| Query parameters | request.query |
string |
Pagination, filtering |
| Path parameters | request.path |
string |
REST resources, nested routes |
| Headers | request.headers |
string |
Authentication, content negotiation |
Web forms
Form submissions are received as request.body.form, which optionally
accepts a schema. Values arrive as strings (or File for file inputs).
form() keeps the last value for repeated field names. To validate repeated
checkbox or multi-select values, read request.body.formEntries(), select the
entries by name, and validate the resulting array.
import p from "pema";
import route from "primate/route";
const Login = p({
email: p.string.email(),
password: p.string.min(8),
});
export default route({
async post(request) {
const form = Login.parse(await request.body.form());
// form.email and form.password are now validated
return "Welcome back!";
},
});This ensures invalid email addresses or too-short passwords never reach your logic.
request.body.form isn't tied to Pema; it accepts any schema that exposes a
parse function; but only Pema integrates directly with Primate to throw a
ParseError that is intercepted and passed to the client.Pema's string schemas cover normalization, cardinality and custom validators.
JSON API calls
When clients send JSON data (e.g. via fetch), you access it with
request.body.json.
import p from "pema";
import route from "primate/route";
const CreateUser = p({
name: p.string,
age: p.uint.min(13), // must be >= 13
});
export default route({
async post(request) {
const user = CreateUser.parse(await request.body.json());
// user is now guaranteed to match schema
return { id: 42, ...user };
},
});You can also attach a body schema directly to the route. Primate validates the
body before returning it from request.body.json().
import p from "pema";
import route from "primate/route";
const CreateUser = p({
name: p.string,
age: p.uint.min(13),
});
export default route({
post: route.with(
{ body: CreateUser, contentType: "application/json" },
async request => {
const user = await request.body.json();
return { id: 42, ...user };
},
),
});Pema object schemas document closed objects, partial updates, derived values and async parsing. Those schema features work with route bodies; this page keeps the HTTP decoding and route-integration contract in one place.
Binary uploads
Raw uploads (e.g. images) are available through request.body.blob as a Blob.
import p from "pema";
import route from "primate/route";
export default route({
async post(request) {
const file = p.blob.parse(await request.body.blob());
return { size: file.size, type: file.type };
},
});Use this for scenarios where the payload itself is the file, not just a form
field. Pema validates the runtime Blob type; apply application-specific size
and media-type policies explicitly. See built-in values.
Query parameters
Query parameters (e.g. ?page=2&filter=active) are strings accessible at
request.query.
import p from "pema";
import route from "primate/route";
const Query = p({
page: p.loose.uint.default(1),
filter: p.string.optional(),
});
export default route({
get: route.with({ query: Query }, request => {
// page is a validated number
return `Showing page ${request.query.get("page")}`;
}),
});Importing this route in client code creates a typed stub. Its query option is
serialized into the URL, and an AbortSignal can cancel the request:
import search from "@/routes/search";
const controller = new AbortController();
const response = await search.get({
query: { filter: "active" },
signal: controller.signal,
});Client stubs also accept URLSearchParams, which is useful for repeated query
keys. Invalid query parameters produce a 400 Bad Request before the route
handler runs. Query lookup remains case-insensitive; schema keys preserve their
declared casing, and requests or schemas with case-variant duplicate keys are
rejected as ambiguous.
Only the generated request stub and route metadata enter the client bundle. The route handler and its transitive imports remain server-only, so importing a typed route from frontend code does not expose its implementation.
Path parameters
Path parameters are extracted from the route definition and exposed on
request.path.
import p from "pema";
import route from "primate/route";
const Path = p({
userId: p.loose.uint,
});
export default route({
get(request) {
const { userId } = Path.parse(request.path);
return `Profile for user ${userId}`;
},
});This is common in REST-style routes.
For route-bound path schemas and asynchronous path normalization, see Routing.
Headers
Request headers are strings, available via request.headers.
import p from "pema";
import route from "primate/route";
const HeadersSchema = p({
authorization: p.string.startsWith("Bearer "),
});
export default route({
get(request) {
const { authorization } = HeadersSchema.parse(request.headers);
const bearer = authorization.slice("Bearer ".length);
/* actually validate the token */
return `Validated token: ${bearer}`;
},
});