QUESTPIE
Schema

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.

View markdown

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.

src/questpie/server/collections/product.ts
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 returnField value type
a refinement of the schema you were givenunchanged
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.

OptionTypeEffect
excludeRecord<string, true>Drops the key from both schemas. The parse then strips it, so an excluded field never reaches the write.
refineRecord<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.

  • 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 beforeValidate and beforeChange sit in the lifecycle.
  • Access control, the other gate on a write.

On this page