Extensions
Validation
Validation is where backend and frontend stop duplicating work. Write the shape of the data once; enforce it on the route that accepts it and on the form that produces it. Drift becomes impossible because there is only one schema.
#Install
npm i @stone-js/validation#Enabling it
Enabled the way every Stone.js module is, with its decorator or with its blueprint. Either one registers the validation provider, so the validator is injectable and the route decorators have something to resolve.
import { Validation } from '@stone-js/validation'
import { StoneApp } from '@stone-js/core'
@Validation()
@StoneApp({ name: 'my-app' })
export class Application {}import { defineStoneApp } from '@stone-js/core'
import { validationBlueprint } from '@stone-js/validation'
export const Application = defineStoneApp({ name: 'my-app' }, [validationBlueprint])#Validate at the boundary
The principle
A boundary should reject malformed input before it reaches the domain, so the domain can assume its inputs are well-formed. Validation belongs at the edge, as a gate, not scattered through business logic.
In Stone.js
validate(rules) is middleware: it checks the event against a schema and rejects with a 422 before your handler runs. validateEvent(event, rules) does the same inline. Both accept any Standard Schema or Zod-like schema.
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
}
}import { z } from 'zod'
import { validateEvent } from '@stone-js/validation'
export const NewTask = z.object({ title: z.string().min(1).max(120) })
const create = ({ tasks }) => (event) => {
validateEvent(event, { body: NewTask }) // throws ValidationError (422) on mismatch
return tasks.add(event.get('body'))
}#The same schema on the frontend
Because the schema is a plain value, the form that creates a task validates against the exact object the API enforces.
import { NewTask } from '../Tasks'
const result = NewTask.safeParse(formValues)
if (!result.success) setErrors(result.error.issues)One schema. It guards the route and shapes the form. They can never disagree.
#Declaring it where the route is
A route can say what it accepts, once, next to itself. The middleware reads that, validates before the handler runs, and publishes each parsed source in the event's metadata under a predictable name.
@Post('/tasks', { validation: CreateTaskSchema }) // one schema means the body
create (event: IncomingHttpEvent): Task {
// The parsed value, not the raw one: a schema coerces and strips, and re-reading the raw body is
// how an application validates one thing and uses another.
const task = event.get<CreateTask>('validatedBody')
return this.tasks.create(task)
}validatedBody, validatedQuery, validatedParams: the name follows the source, so a handler needs no import and nothing to remember.
#Without a router, and without the route
The same thing said on the handler, for a single-handler service, a CLI command or a browser event, anything with no route to hang it on. @Validate owns its own key, so the module works with a router and without one.
@Validate({ body: CreateTaskSchema, query: ListQuerySchema })
handle (event: IncomingEvent): Task { … }#Schemas as classes, registered by name
A schema that needs services (translated messages, a repository to check uniqueness against) is a class the container resolves, so its rules() can use what it was given. Register it once and refer to it by name from anywhere: the route stops importing schemas, and a shared query filter is declared in exactly one place.
@ValidationSchema('listQuery')
export class ListQuerySchema implements IValidationSchema {
constructor ({ i18n }) { this.i18n = i18n }
rules () {
return { query: z.object({ page: z.coerce.number().default(1) }) }
}
}
// Anywhere: @Get('/tasks', { validation: 'listQuery' }) or @Validate('listQuery')#Rules and sources
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.
validate({
params: z.object({ id: z.string().uuid() }),
query: z.object({ page: z.coerce.number().default(1) }),
body: NewTask
})#Bring any schema
Validation speaks the Standard Schema interface, so Zod, Valibot, ArkType and others work as is. For a library that is not yet Standard Schema, adapt it explicitly with fromZod or fromStandard; the rest of your code stays the same.
import { fromZod, fromStandard } from '@stone-js/validation'
const NewTask = fromZod(zodSchema) // wrap a Zod schema
const Filter = fromStandard(anyStandard) // wrap any Standard Schema#The failure shape
A failed check throws a ValidationError the kernel maps to 422, with the issues attached, so clients get a precise, structured error without you writing the plumbing. Need it inline instead of as middleware? validateEvent throws the same error; or resolve the Validator service to validate arbitrary values.
constructor ({ validator }) { this.validator = validator }
parse (input: unknown) {
return this.validator.validate(input, NewTask) // throws ValidationError (422) on mismatch
}