Validation
Every collection compiles a create schema and an update schema from its fields, and every create and update parses one of them before the row reaches Postgres.
A field type already carries the obvious rule. f.email() wants an email,
f.text(120) stops at 120 characters, f.select([...]) takes only its own
values. This page is about the rule your field type has no way to say.
What is already there
You never write a schema file. Declare the fields and both schemas exist:
insertSchema for create, and updateSchema for update with every field
optional, so a patch carries only what changed.
Both also include id, createdAt and updatedAt, which is how a caller may
supply its own id on create.
One rule the field type cannot say
A product SKU has a shape. The stored settings blob has a shape. Neither
f.text() nor f.json() can express either one.
import { collection } from "#questpie/factories";
import { z } from "zod";
const isSku = (value: unknown) =>
typeof value === "string" && /^SKU-\d{6}$/.test(value);
export default collection("product")
.fields(({ f }) => ({
name: f.text(120).required(),
// Refine the derived schema. The value type stays `string`.
sku: f
.text(20)
.required()
.zod((schema) => schema.refine(isSku, "Use SKU-000000")),
// Replace it. The value type narrows to the schema's output. Shown on
// f.json() because it is the shortest way to demonstrate a replacement.
// For keys this fixed, f.object() is the better field: it gives each one
// its own schema, label and admin control, in the same jsonb column.
settings: f.json().zod(() =>
z.object({
theme: z.enum(["light", "dark"]),
compact: z.boolean(),
}),
),
internalNotes: f.text(),
}))
.validation({ exclude: { internalNotes: true } })
.hooks({
// Runs before either schema parses, so the uppercase counts
beforeValidate: ({ data }) => {
if (typeof data.sku === "string") data.sku = data.sku.toUpperCase();
},
})
.title(({ f }) => f.name);The result
POST /api/product carrying a valid name and sku: "sku-1". The hook
uppercases the sku to SKU-1, the refinement rejects that, and no row is
written:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"fieldErrors": [{ "path": "sku", "message": "Use SKU-000000" }]
}
}path is the Zod issue path joined with dots, so a bad theme inside
settings comes back as settings.theme.
Validation errors covers the rest of the body
and how to raise your own.
Refine or replace one field
.zod(fn) hands you the field's derived schema and takes back a new one. What
you return decides the field's TypeScript value type.
| What you return | Field value type |
|---|---|
| a refinement of the schema you were given | unchanged |
a schema with a concrete output, z.object({...}) | narrows to that output |
The argument is typed ZodType, which carries .refine(), .check() and
.transform() but not .regex() or .min(). Those live on ZodString, so put
the test inside .refine() or return a fresh z.string().
`.zod()` validates, `.$type<T>()` only types
f.json().$type<Layout>() sets the TypeScript value type and adds no runtime
check. Pair it with .zod() when the value must be enforced on the way in
too.
Relation and upload fields skip the overlay
Their id formats are app-defined, so both schemas keep the column-derived
shape for those fields and ignore their .zod(). Fields marked
.inputFalse() are skipped the same way. Check relation targets in
beforeChange instead.
Tune the whole collection
.validation() does not switch validation on. It is already on. The method
exists to drop fields from both schemas or to layer a refinement across several
of them at once.
| Option | Type | Effect |
|---|---|---|
exclude | Record<string, true> | Drops the key from both schemas. The parse then strips it, so an excluded field never reaches the write. |
refine | Record<string, (schema) => schema> | Wraps the named field's schema, on top of that field's own .zod(), for create and update alike. |
.validation({
refine: {
name: (schema) => schema.refine(isTitleCase, "Use title case"),
},
exclude: { internalNotes: true },
})Prefer .zod() for a single field. Reach for .validation({ refine }) when you
are tuning several fields together or keeping validation out of the field list.
One call, not two
.validation() records its options rather than merging them, so a second call
replaces the first. Pass every exclude and refine together.
Normalize input first
beforeValidate runs after beforeOperation and before the parse. Its data is
the raw input and you mutate it in place, so trimming, defaulting and deriving
all land in time to be checked. beforeChange runs on data that already passed,
too late to rescue a value the schema rejected. The
Hooks page has the full lifecycle.
When each gate runs
Create and update both run beforeOperation, then the collection's access rule,
then beforeValidate, then field write-access and the schema parse, then
beforeChange, then the write. The two middle gates swap order: create checks
field write-access before parsing, update parses first. Neither ordering lets a
bad or denied write reach Postgres, so the only visible difference is which
error comes back when a request trips both, FORBIDDEN or VALIDATION_ERROR.
Globals are never parsed
A global has no .validation() and no beforeValidate. Its update schema is
built and published to OpenAPI and MCP, but the global write path never parses
it. Enforce a global's rules in beforeChange.
Related
- Validation errors, the response body, catching it in the client, and raising your own from a hook.
- Fields, the field types whose own rules seed every schema.
- Hooks, where
beforeValidateandbeforeChangesit in the lifecycle. - Access control, the other gate on a write.