Validation
@bext-stack/framework/validation validates a server-action FormData, a
JSON request body, or any plain object against a schema you write in
TypeScript, and hands you back a typed, coerced value or a list of
per-field errors. It is the app-developer counterpart to the Rust
Request Validator capability: the capability
validates config files and queue payloads server-side; this module is what
you reach for inside a PRISM action.
It is zod-shaped so the mental model transfers — v.object({...}),
.parse / .safeParse — but it is a few hundred lines of pure TypeScript
with zero dependencies, and it understands the all-strings world of HTML
forms (numeric strings become numbers, checkboxes become booleans, repeated
fields become arrays).
When To Use It#
- Server actions and API routes — validate
requestbefore you trust it. - Multi-error forms — show every field's problem at once, not just the first, while keeping the user's typed values on a re-render.
- Typed input — turn
FormData(all strings) into a typed object with numbers, booleans and enums, checked at the boundary.
For a single ad-hoc type check, an inline if is simpler. Reach for this when
the shape is real, shared, or shown back to a user.
Rule order is meaningful. errorsByField returns the first message per
field, so declaring .nonempty("Required") before .email("Malformed") gives
you the natural "required THEN format" UX for free — while errorsByFieldAll
still exposes every failing check.
Schema builders#
Reach every builder through the v namespace.
| Builder | Produces | Notable methods |
|---|---|---|
v.string(opts?) |
string |
.trim() .toLowerCase() .nonempty() .min(n) .max(n) .length(n) .regex(re) .email() .url() |
v.number(opts?) |
number (coerced from numeric strings) |
.int() .min(n) .max(n) .positive() .nonnegative() |
v.boolean() |
boolean |
checkbox semantics — absent → false, "on"/"true"/"1" → true, never errors |
v.enum([...] as const) |
the union of the literals | — |
v.literal(x) |
the exact value x |
— |
v.array(inner) |
T[] |
.min(n) .max(n) .nonempty() — a single repeated form field counts as a 1-element list |
v.object(shape) |
the inferred object type | .partial() (all optional) .strict() (reject unknown keys) |
Every builder is chainable with .optional(), .nullable(),
.default(value), .refine(test, message), and .transform(fn). The
opts on v.string / v.number override the built-in required and
wrong-type messages (the ones the constraint methods can't reach) — that's
the hook for localised copy.
Validating a request#
validate(input, schema) reads the body for you — FormData,
application/json, or x-www-form-urlencoded — then validates:
import { v, validate, errorsByField, type Infer } from "@bext-stack/framework/validation";
const Signup = v.object({
email: v.string().trim().nonempty("Email required").email("Looks malformed"),
username: v.string().trim().min(3).regex(/^[a-z0-9_]+$/i, "letters, digits, _ only"),
age: v.number().int().min(13).max(120),
});
type Signup = Infer<typeof Signup>; // { email: string; username: string; age: number }
export async function action({ request }: { request: Request }) {
const result = await validate(request, Signup);
if (!result.ok) {
// Return a plain object → PRISM re-renders the page with actionData,
// so the form shows per-field errors and keeps the user's typing.
return { ok: false, errors: errorsByField(result.errors) };
}
const user = result.data; // fully typed & coerced — user.age is a number
await createUser(user);
return new Response(null, { status: 303, headers: { Location: "/welcome" } });
}
Prefer to skip the boilerplate? validated(schema, handler) wraps an action so
your handler only runs on valid input and receives the typed data; invalid
input short-circuits to a { ok: false, errors, values } re-render payload:
export const action = validated(Signup, async (data, { request }) => {
await createUser(data); // data is Signup, typed & coerced
return new Response(null, { status: 303, headers: { Location: "/welcome" } });
});
Coercion rules#
Because HTML forms send everything as strings, the builders coerce rather than reject:
v.number()parses a numeric string ("27"→27); a blank or absent field isrequired, a non-numeric string isinvalid.v.boolean()follows checkbox semantics: an absent checkbox isfalse, a present one ("on") istrue. It never errors.v.array(inner)treats a single value as a one-element list, so a repeated form field (tag=a&tag=b) and a singletag=aboth validate.- Nested
v.object/v.arrayreport dotted / bracketed paths —address.zip,tags[2]— so the error maps straight onto your inputs.
Error helpers#
| Helper | Returns | Use for |
|---|---|---|
errorsByField(errors) |
{ field: firstMessage } |
the common one-message-per-input form |
errorsByFieldAll(errors) |
{ field: string[] } |
showing every problem on a field |
stringValues(raw) |
{ field: string } |
re-populating a rejected form |
formToObject(fd) |
plain object | when you want the raw object before validating |
.parse(input) throws a ValidationError (carrying .errors) on failure;
.safeParse(input) never throws and returns a ValidationResult.
Relationship to the Request Validator capability#
The Rust Request Validator is a server-side plugin
trait for validating config files, decoded webhook payloads, and queue messages
before they reach a handler — JSON-Schema / CUE backed, selected by
BEXT_VALIDATOR_PROVIDER. This module is the in-isolate TypeScript path for
request/form validation in your app code: no provider to configure, no host
round-trip, and the schema lives next to the code that uses it. Use the
capability at plugin / config boundaries; use this in your PRISM routes.
Try It#
A runnable demo — a signup form that reports every field error in one pass and
303s on success — lives at
demo.bext.dev/examples/validation,
source in sites/demo/src/app/examples/validation/page.tsx.
See Also#
- Request Validator — the Rust capability this complements.
- Server Actions — where validation runs.
- PRISM Data (loader / action) — the
actionDatare-render flow. - Application Toolkit — the rest of the TypeScript app layer.