8.1 KiB
@wrnexus/validation
One fluent schema, validated on the server (API bodies, env vars) and mirrored to an eval-free browser validator for forms.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
Define a schema once with the fluent v builder, then reuse it in three places: .parse() runs server-side and returns coerced values plus per-field errors; .describe() emits a plain-JSON SchemaDescriptor that the browser runtime interprets (no eval, no bundled validator); and helpers like parseBody and parseEnv wire schemas straight into API routes and startup config. The server rule logic (applyRule/checkField) and the client runtime (VALIDATE_RUNTIME) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in app/schemas/.
Installation
bun add @wrnexus/validation
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
The v builder
import { v } from "@wrnexus/validation";
| Factory | Returns | Field methods |
|---|---|---|
v.string() |
StringSchema |
email(), url(), uuid(), date(), length(n), oneOf(string[]), pattern(re), trim(), min(n), max(n) |
v.number() |
NumberSchema |
integer(), positive(), oneOf(number[]), min(n), max(n) |
v.boolean() |
BooleanSchema |
(base methods only) |
v.object(fields) |
ObjectSchema |
parse(input), describe() |
Every field schema is chainable and shares these base methods:
min(n, message?)/max(n, message?)— for strings, bounds the length; for numbers, bounds the value.optional()— an empty/missing value passes instead of erroring"Required".label(text)— human label carried into the descriptor.default(value)— value substituted when the field is absent (impliesoptional).refine(fn, message?)— server-only predicate.fnreturnstrue(ok),false(usemessage), or astring(that error). Not serialized to the client.
Each string rule accepts an optional trailing message to override the default error text.
ObjectSchema
schema.parse(input: unknown): ParseResult
schema.describe(): SchemaDescriptor
parse coerces each field (strings stay strings, v.number() runs Number(), v.boolean() treats true / "true" / "on" as true), applies its rules and refinements, fills in default() values, and returns:
interface ParseResult<T = Record<string, unknown>> {
ok: boolean; // true when errors is empty
value: T; // coerced values (present pass or fail)
errors: Record<string, string>; // field name → first failing message
}
describe() returns the JSON bridge for the client:
interface SchemaDescriptor {
type: "object";
fields: Record<string, FieldDescriptor>;
}
interface FieldDescriptor {
type: "string" | "number" | "boolean";
optional?: boolean;
label?: string;
trim?: boolean; // strings only
rules: RuleDescriptor[];
}
Rules and coercion
RuleDescriptor is a discriminated union of the serializable rules — min, max, length, email, url, uuid, date, oneOf, pattern, integer. Two exported functions apply them and are shared by the server (the client runtime reimplements the same logic):
applyRule(type, rule, value): string | null— validate one already-coerced value against one rule.checkField(desc, raw): { value, error }— coerce and validate one field. Empty input (undefined/null/"") is"Required"unlessoptional. Strings withtrimare trimmed first. Numbers that failNumber()yield"Must be a number".
Notes on specific rules: email/url/uuid test built-in regexes; date uses Date.parse; pattern reconstructs a RegExp from its source/flags and passes silently if the pattern is invalid; integer requires Number.isInteger; positive() is implemented as min(Number.MIN_VALUE).
API helpers
invalid(errors: Record<string, string>): Response // ready 400 { ok:false, errors }
parseBody<T>(schema, req):
Promise<{ ok: true; value: T } | { ok: false; response: Response }>
parseBody reads the request body from JSON, application/x-www-form-urlencoded, or multipart/form-data, validates it, and on failure hands back a ready 400 Response.
Environment config
parseEnv<T>(schema: ObjectSchema, source?): T
Validates env vars (from Bun.env, falling back to process.env) against a schema and coerces them (PORT → number, DEBUG → boolean). On any problem it throws one error listing every offending variable, so misconfiguration fails fast at startup.
Client runtime (from runtime.ts)
renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string
VALIDATE_RUNTIME: string
renderSchemasScriptproduceswindow.__wireSchemas = { name: descriptor, … };to inline in the page.VALIDATE_RUNTIMEis a self-contained, eval-free IIFE string. Injected as a<script>, it binds everyform[data-schema]and validates on submit and blur, writing messages into[data-error="<field>"]elements and togglingaria-invalid/.wire-invalid. On a valid submit itfetches the formactionas JSON (attaching thewire-csrfcookie as anx-csrf-tokenheader), then followsdata-redirect/ aredirectin the response, surfaces server-side field errors, and fireswire:success/wire:errorevents. It exposeswindow.__wireValidate.init(root)and self-initializes onDOMContentLoaded.
Usage
Define a schema and validate an API body:
import { v, parseBody } from "@wrnexus/validation";
export const signupSchema = v.object({
email: v.string().trim().email(),
password: v.string().min(8).max(200),
age: v.number().integer().min(13).max(120).optional(),
role: v.string().oneOf(["user", "admin"]).default("user"),
agree: v.boolean(),
});
// inside a route handler
const result = await parseBody(signupSchema, req);
if (!result.ok) return result.response; // ready 400 with field errors
const { email, password, role } = result.value;
Server-only refinement:
const schema = v.object({
username: v
.string()
.min(3)
.refine((name) => !RESERVED.has(String(name)), "That name is taken"),
});
Validate environment at startup:
import { v, parseEnv } from "@wrnexus/validation";
export const env = parseEnv(
v.object({
DATABASE_URL: v.string().min(1),
PORT: v.number().integer().default(3000),
DEBUG: v.boolean().optional(),
}),
);
// throws one readable error listing every bad variable if misconfigured
Wire the same schema into the browser:
import { renderSchemasScript, VALIDATE_RUNTIME } from "@wrnexus/validation";
import { signupSchema } from "./app/schemas/signup.ts";
const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })}</script>
<script>${VALIDATE_RUNTIME}</script>`;
// render a <form data-schema="signup"> with [data-error="email"] etc.
Requirements / Notes
- Bun-only.
parseEnvreadsBun.env(falling back toprocess.env);parseBodyandinvaliduse the WebRequest/ResponseAPIs that backBun.serve. - Refinements (
refine) run only server-side and are never serialized — client and server agree on every other rule because both interpret the sameRuleDescriptorlist. - No runtime dependencies. Ships as TypeScript source (
src/index.ts) executed directly by Bun. - Pairs with the WrNexus server (
@wrnexus/core) for route handlers and the SSR layer that injectsrenderSchemasScript/VALIDATE_RUNTIME.