Stone.jsDocs
Paradigm

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

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

app/Application.tsdeclarativeimperative
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.

app/Tasks.tsdeclarativeimperative
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.

app/pages/NewTaskPage.tsxtsx
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.

app/TasksController.tsts
@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.

app/CreateTask.tsts
@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.

app/schemas/ListQuerySchema.tsts
@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.

app/Tasks.tsts
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.

app/schemas.tsts
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.

app/Tasks.tsts
constructor ({ validator }) { this.validator = validator }

parse (input: unknown) {
  return this.validator.validate(input, NewTask)   // throws ValidationError (422) on mismatch
}

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)