# Validation (/docs/schema/validation)

---
title: Validation
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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:

```json
{
	"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](/docs/schema/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()`.

<Callout type="info" title="`.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.
</Callout>

<Callout type="warn" title="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.
</Callout>

## 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.        |

```ts
.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.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/schema/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`.

<Callout type="warn" title="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`.
</Callout>

## Related

- **[Validation errors](/docs/schema/validation/errors)**, the response body,
  catching it in the client, and raising your own from a hook.
- **[Fields](/docs/schema/fields)**, the field types whose own rules seed every
  schema.
- **[Hooks](/docs/schema/hooks)**, where `beforeValidate` and `beforeChange` sit
  in the lifecycle.
- **[Access control](/docs/schema/access-control)**, the other gate on a write.
