bext.dev
DocsApplication Toolkit
Application Toolkit4 min read
On this page

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 request before 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.

Tip

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:

ts
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:

ts
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 is required, a non-numeric string is invalid.
  • v.boolean() follows checkbox semantics: an absent checkbox is false, a present one ("on") is true. 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 single tag=a both validate.
  • Nested v.object / v.array report dotted / bracketed pathsaddress.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#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.