@wrnexus/validation
Typed schemas, coercion, validation, and browser descriptors.
bun add @wrnexus/validation@0.2.15One fluent schema, validated on the server (API bodies, env vars) and mirrored to an eval-free browser validator for forms.
Part of the WRNexusJS 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 wrnexus npm 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 WRNexusJS server (
@wrnexus/core) for route handlers and the SSR layer that injectsrenderSchemasScript/VALIDATE_RUNTIME.
Complete TypeScript API
This declaration is generated from the exact published package and lists its exported functions, classes, interfaces, and types.
/**
* Client-side validation. `renderSchemasScript` bakes the discovered schema
* descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
* eval-free validator that reads them and validates every `form[data-schema]`
* on submit and blur, writing messages into `[data-error="<field>"]` elements.
* The rule logic mirrors `checkField`/`applyRule` in index.ts.
*/
/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
declare function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string;
declare const VALIDATE_RUNTIME: string;
/**
* @wrnexus/validation — one schema, validated on the server (API) and the browser
* (forms). A schema is a fluent builder; `.parse()` runs server-side and returns
* coerced values + field errors, while `.describe()` emits a JSON descriptor the
* eval-free client validator interprets. Define schemas once in `app/schemas/`.
*/
type RuleDescriptor = {
kind: "min";
n: number;
message?: string;
} | {
kind: "max";
n: number;
message?: string;
} | {
kind: "length";
n: number;
message?: string;
} | {
kind: "email";
message?: string;
} | {
kind: "url";
message?: string;
} | {
kind: "uuid";
message?: string;
} | {
kind: "date";
message?: string;
} | {
kind: "oneOf";
values: (string | number)[];
message?: string;
} | {
kind: "pattern";
source: string;
flags?: string;
message?: string;
} | {
kind: "integer";
message?: string;
};
interface FieldDescriptor {
type: "string" | "number" | "boolean";
optional?: boolean;
label?: string;
/** Trim string input before validating. */
trim?: boolean;
rules: RuleDescriptor[];
}
interface SchemaDescriptor {
type: "object";
fields: Record<string, FieldDescriptor>;
}
interface ParseResult<T = Record<string, unknown>> {
ok: boolean;
/** Coerced values (present whether or not validation passed). */
value: T;
/** Field name → message, only for fields that failed. */
errors: Record<string, string>;
}
/**
* Apply one rule to an already-coerced value. Shared by the server; the client
* runtime (runtime.ts) mirrors this exactly. Returns an error message or null.
*/
declare function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null;
/** Coerce + validate one field against its descriptor. */
declare function checkField(desc: FieldDescriptor, raw: unknown): {
value: unknown;
error: string | null;
};
/** A server-only refinement (a predicate that can't be serialized to the client). */
type Refinement = {
fn: (value: unknown) => boolean | string;
message?: string;
};
declare abstract class FieldSchema {
abstract readonly type: "string" | "number" | "boolean";
protected _optional: boolean;
protected _label?: string;
protected _default?: unknown;
protected rules: RuleDescriptor[];
protected refinements: Refinement[];
optional(): this;
label(label: string): this;
/** Value used when the field is absent (implies optional). */
default(value: unknown): this;
min(n: number, message?: string): this;
max(n: number, message?: string): this;
/**
* Custom SERVER-side validation. `fn` returns true (ok), false (use `message`),
* or a string (that error). Not mirrored to the client validator.
*/
refine(fn: (value: unknown) => boolean | string, message?: string): this;
getDefault(): unknown;
runRefinements(value: unknown): string | null;
describe(): FieldDescriptor;
}
declare class StringSchema extends FieldSchema {
readonly type: "string";
private _trim;
email(message?: string): this;
url(message?: string): this;
uuid(message?: string): this;
date(message?: string): this;
length(n: number, message?: string): this;
oneOf(values: string[], message?: string): this;
trim(): this;
pattern(re: RegExp, message?: string): this;
describe(): FieldDescriptor;
}
declare class NumberSchema extends FieldSchema {
readonly type: "number";
integer(message?: string): this;
positive(message?: string): this;
oneOf(values: number[], message?: string): this;
}
declare class BooleanSchema extends FieldSchema {
readonly type: "boolean";
}
declare class ObjectSchema {
private readonly fields;
constructor(fields: Record<string, FieldSchema>);
/** Validate an input object; returns coerced values + per-field errors. */
parse(input: unknown): ParseResult;
describe(): SchemaDescriptor;
}
/** The fluent schema builder. */
declare const v: {
string: () => StringSchema;
number: () => NumberSchema;
boolean: () => BooleanSchema;
object: (fields: Record<string, FieldSchema>) => ObjectSchema;
};
/**
* Validate environment variables against a schema at startup. Values are read
* from `Bun.env` / `process.env` by default and coerced by the schema (so
* `PORT` becomes a number, `DEBUG` a boolean). On any problem it throws ONE
* readable error listing every offending variable, so misconfiguration fails
* fast with an actionable message instead of surfacing deep inside the app.
*
* export const env = parseEnv(v.object({
* DATABASE_URL: v.string().min(1),
* PORT: v.number(),
* }));
*/
declare function parseEnv<T = Record<string, unknown>>(schema: ObjectSchema, source?: Record<string, string | undefined>): T;
/** A 400 response carrying field errors, for API routes. */
declare function invalid(errors: Record<string, string>): Response;
/**
* Parse a request's JSON body against a schema. On failure returns
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
*/
declare function parseBody<T = Record<string, unknown>>(schema: ObjectSchema, req: Request): Promise<{
ok: true;
value: T;
} | {
ok: false;
response: Response;
}>;
export { type FieldDescriptor, ObjectSchema, type ParseResult, type RuleDescriptor, type SchemaDescriptor, VALIDATE_RUNTIME, applyRule, checkField, invalid, parseBody, parseEnv, renderSchemasScript, v };
Examples
Copy-ready examples taken from this package's published documentation.
Example 1
bun add @wrnexus/validationExample 2
import { v } from "@wrnexus/validation";Example 3
schema.parse(input: unknown): ParseResult
schema.describe(): SchemaDescriptorExample 4
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
}