Almost every app validates the same data twice: once in the form, so the user gets fast feedback, and once in the API, because the form cannot be trusted. Two copies of the same rules, in two languages, maintained by two habits. They drift, and the drift is where the bugs live.

The two rulesets always diverge

Someone tightens the title to 120 characters on the server for a database column, and forgets the form. Someone adds a client-side check for a new field, and the API never learns about it. Each divergence is either a confusing rejection the user cannot see coming, or a bad row the API let through. The root cause is not carelessness: it is that there are two sources of truth for one fact.

Write the shape once

Because Stone.js runs one language across backend and frontend, the schema can be a single plain value that both sides import. @stone-js/validation enforces it at the API boundary with the validate middleware; the same schema shapes the form. There is only one description of the data, so the two can never disagree.

One schemaZod / Standard SchemaAPIvalidate() → 422FormsafeParse() in the UI
One schema, two consumers: the route that accepts the data and the form that produces it. They cannot drift because there is only one definition.

Guard the route

validate(rules) is middleware. It checks the event against the schema and rejects malformed input with a 422 before your handler runs, so the handler can assume its input is already well-formed.

app/Tasks.tsts
import { z } from 'zod'
import { validate } from '@stone-js/validation'
import { EventHandler, Post } from '@stone-js/router'

export const NewTask = z.object({ title: z.string().min(1).max(120) })

@EventHandler('/tasks')
export class TaskController {
  @Post('/', { middleware: [validate({ body: NewTask })] })
  create (event) {
    return this.tasks.add(event.get('body'))   // reaches here only if body matched NewTask
  }
}

Reuse it in the form

The form imports the exact same NewTask and validates the values the user is typing. Because it is the same object the API enforces, a value the form accepts is a value the API accepts.

app/pages/NewTaskPage.tsxtsx
import { NewTask } from '../Tasks'

const result = NewTask.safeParse(formValues)
if (!result.success) setErrors(result.error.issues)

A precise failure, for free

A failed check throws a ValidationError the kernel maps to a 422 with the issues attached, so clients receive a structured error and you write no plumbing. Rules are a map from a source (body, query, params) to a schema, so one middleware can validate several parts of an event at once.

Why it matters

  • Drift is impossible. There is one schema, not two, so the form and the API cannot disagree about what is valid.
  • No lock-in. Validation speaks the Standard Schema interface, so Zod, Valibot and ArkType all work as they are.
  • The boundary stays a boundary. Malformed input is rejected at the edge, and the domain keeps its assumption that inputs are clean.

The full API, including validateEvent for inline checks and the Validator service, is in Validation. It also feeds OpenAPI: the same schemas become your public contract, so the document cannot drift from what you validate either.