S
Data Modeling

Validation

Check every request body with Zod before a handler runs and see how failures are reported

Validation decides what a request body may contain. You describe the rules with Zod, a TypeScript schema library, and StarkForge applies them before a handler touches the database. A body that breaks a rule is answered with a 422 error that lists every problem. A body that passes reaches the handler with only the fields you declared. This page explains the idea, and Validation Schemas shows how to write the rules in depth.

Validation Groups

A collection keeps its rules in validations: a set of named groups, where each group maps body field names to Zod rules. A route opts in by naming a group in validation:

TypeScript
// src/collections/products.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";

export const products = collection({
  type: "crud",
  route: "products",
  model: "Product",
  schema: {
    name: fields.requiredString,
    price: fields.requiredNumber,
    sku: { mongoose: { type: String, unique: true, sparse: true }, zod: z.string().optional() },
  },
  validations: {
    create: { name: z.string().min(1).max(120), price: z.number().min(0), sku: z.string().optional() },
    update: { name: z.string().min(1).max(120).optional(), price: z.number().min(0).optional() },
  },
  routes: defineRoutes([
    { method: "post", path: "/", handler: "create", auth: "authenticated", roles: ["editor", "admin"], validation: "create" },
    { method: "patch", path: "/update/:id", handler: "update", auth: "authenticated", roles: ["editor", "admin"], validation: "update" },
  ]),
});

The create group requires a name and a price, and the price may not be negative. The update group makes both fields optional, because an update sends only what changes. Any route may use any group, so several routes can share one.

A route with validation: false, or with no validation at all, accepts no body fields: StarkForge discards the whole body before the handler runs. That suits read and remove. It is a common mistake on create and update, as Routes explains.

What the Handler Receives

A handler never sees the raw body. It receives what the rules produce, and Zod keeps only the keys that the group lists. This update sends a field that is not in the update group, and a MongoDB operator:

Text
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json

{
  "price": 79.9,
  "role": "admin",
  "$set": {
    "price": 0
  }
}
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product updated successfully.",
  "data": {
    "name": "Mechanical keyboard",
    "price": 79.9,
    "sku": "KB-001",
    "_id": "665f1c2e8b3a4d0012a10001",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

Only price changed. role is not in the group, so validation dropped it. $set never reached validation, because the input sanitizer removes keys that start with $ earlier in the pipeline.

Rules can also clean values. z.string().trim() removes surrounding spaces and z.coerce.number() turns text into a number, and the handler receives the cleaned value.

Dropped Without Notice

A field that is missing from the group is removed without an error. When the data of a client seems to be ignored, compare the body with the validation group first.

What a Failure Looks Like

A body that breaks a rule never reaches the handler. StarkForge answers 422 with the code VALIDATION_FAILED. This request breaks two rules of the create group:

Text
POST /api/v1/products
Content-Type: application/json

{
  "name": "",
  "price": -5
}
Text
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "success": false,
  "message": "name: Too small: expected string to have >=1 characters",
  "code": "VALIDATION_FAILED",
  "issues": [
    {
      "path": "name",
      "message": "Too small: expected string to have >=1 characters",
      "code": "too_small"
    },
    {
      "path": "price",
      "message": "Too small: expected number to be >=0",
      "code": "too_small"
    }
  ],
  "requestId": "75d55dde-15df-4930-929e-d3302defddf4"
}

The message shows the first problem as the field path followed by its message. The issues list holds every problem, so a front end can show each message next to its field. Each issue has three keys:

KeyMeaning
pathThe field, joined with dots for nested values, such as tags.1. It is empty when the whole body has the wrong shape
messageWhat is wrong, in Zod's words or in the message that your rule sets
codeThe kind of problem, taken from Zod: invalid_type, too_small, too_big, invalid_format or invalid_value

The code invalid_type covers a missing field and a value of the wrong type. With price missing, the issue reads Invalid input: expected number, received undefined. When the whole body has the wrong shape, such as an array sent to a route that expects an object, path is empty and the message starts with body:.

The schema is a second line of defense. Mongoose checks options such as required and enum when it saves a document, and a failure there is reported in the same format, with a Mongoose code such as required. ValidationError is the class behind these answers. It is exported, so your own code can throw it, and Errors describes the error classes.

JSON, Forms and Multipart Bodies

JSON keeps its types, so z.number() accepts the number 12. A URL-encoded form and a multipart upload send every value as text, so the same rule fails with Invalid input: expected number, received string. Pick the rule that matches the body that your clients send:

BodyValues arrive asRule for a numberRule for a true or false value
JSONJSON typesz.number()z.boolean()
URL-encoded formTextz.coerce.number()z.stringbool()
Multipart uploadTextz.coerce.number()z.stringbool()

A route accepts a multipart body when it has files. The number helpers of fields already convert text, and the array helpers accept a JSON array or comma-separated text. fields.boolean expects a real boolean, so use z.stringbool() for a flag that arrives in a form.

Booleans in Forms

Avoid z.coerce.boolean() for form values. It turns the text false into true, because any non-empty text counts as true. z.stringbool() reads "true" and "false" correctly and rejects other text.

Rules From Helpers

A group can use the helpers of fields as rules, for example email: fields.email. Each helper is a wrapped field with a Zod half and a Mongoose half, and a group uses only the Zod half. A rule that is neither a Zod type nor a wrapped field stops the app from building:

Text
ConfigurationError (CONFIGURATION_ERROR): Validation 'items.create' → field 'name' is neither a Zod type nor a { mongoose, zod } field.
hint: Use `z.string()`... or one of the helpers exported as `fields`.

When Validation Runs

Inside a handler, validation comes after your before actions, so those actions see the raw body, and before anything is saved. For update, the document is loaded first, so a missing document or one that belongs to someone else answers 404 or 403 before the body is checked. Nothing is encrypted, hashed, uploaded or saved until the rules have passed.

Where to Go Next

Responses

See the shape of success responses and pagination metadata.

Errors

Learn the error shape, the codes and the classes behind them.

:::