# Validation errors (/docs/schema/validation/errors)

---
title: Validation errors
description: A rejected write comes back as one HTTP 400 body with a field error per Zod issue, and a hook can raise the same shape for rules a single field schema cannot see.
kind: guide
package: questpie
---

Your collection rejected a write. This page is what the caller sees, how the
typed client reads it, and how you raise a failure of your own.

## The body

A failed parse becomes an `ApiError` with code `VALIDATION_ERROR`, which maps to
HTTP 400. The response wraps it under an `error` key:

```json
{
	"error": {
		"code": "VALIDATION_ERROR",
		"message": "Validation failed",
		"fieldErrors": [
			{
				"path": "settings.theme",
				"message": "Invalid option: expected one of \"light\"|\"dark\""
			}
		]
	}
}
```

| Key           | What it holds                                                                     |
| ------------- | --------------------------------------------------------------------------------- |
| `code`        | `VALIDATION_ERROR` from a schema parse, `BAD_REQUEST` from `ApiError.badRequest`. |
| `message`     | A summary line. QUESTPIE translates it when the error carries a translation key.  |
| `fieldErrors` | One entry per Zod issue, each with a dotted `path` and its own `message`.         |
| `cause`       | The raw Zod issues as a string. `stack` joins it in development.                  |

Nothing is written when the parse fails. No row, no `beforeChange`, no
`afterChange`.

## Reading it in the client

The typed client throws `QuestpieClientError` on any non-2xx response and parses
the envelope for you.

```ts
import { QuestpieClientError } from "questpie/client";

try {
	await client.collections.product.create({ name: "Chair", sku: "sku-1" });
} catch (error) {
	if (
		error instanceof QuestpieClientError &&
		error.isCode("VALIDATION_ERROR")
	) {
		error.getFieldError("sku"); // { path: "sku", message: "Use SKU-000000" }
		error.getFieldErrorsMap(); // { sku: ["Use SKU-000000"] }
	}
}
```

The admin form does the same thing and sets each message on its own input, so a
rule you wrote in the collection file shows up under the right control with no
extra work.

## Rules a field schema cannot see

A field schema sees one field. When the rule spans two, put it in
`beforeChange` and throw `ApiError.badRequest`. It carries the same
`fieldErrors` shape and the same HTTP 400.

```ts title="src/questpie/server/collections/booking.ts"
import { collection } from "#questpie/factories";
import { ApiError } from "questpie/errors";

export default collection("booking")
	.fields(({ f }) => ({
		startsAt: f.datetime().required(),
		endsAt: f.datetime().required(),
	}))
	.hooks({
		beforeChange: ({ data }) => {
			if (data.startsAt && data.endsAt && data.endsAt <= data.startsAt) {
				throw ApiError.badRequest("End must be after start", [
					{ path: "endsAt", message: "Must be after startsAt" },
				]);
			}
		},
	})
	.title(({ f }) => f.startsAt);
```

The caller now gets `code: "BAD_REQUEST"` and a `fieldErrors` entry pointing at
`endsAt`, which the admin renders under that input like any other failure.

<Callout type="info" title="Guard the fields you compare">
	`beforeChange` runs on updates too, where a patch may carry one date and not
	the other. Check both are present before comparing them.
</Callout>

## Related

- **[Validation](/docs/schema/validation)**, the schemas that produce these
  errors and the three ways to shape them.
- **[Hooks](/docs/schema/hooks)**, where `beforeChange` sits and what its `ctx`
  carries.
- **[Client SDK](/docs/client/sdk)**, the rest of `QuestpieClientError`.
