W WRNexusJS
Security

@wrnexus/validation

Typed schemas, coercion, validation, and browser descriptors.

bun add @wrnexus/validation@0.2.15
One 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";
FactoryReturnsField methods
v.string()StringSchemaemail(), url(), uuid(), date(), length(n), oneOf(string[]), pattern(re), trim(), min(n), max(n)
v.number()NumberSchemainteger(), positive(), oneOf(number[]), min(n), max(n)
v.boolean()BooleanSchema(base methods only)
v.object(fields)ObjectSchemaparse(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 (implies optional).
  • refine(fn, message?)server-only predicate. fn returns true (ok), false (use message), or a string (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" unless optional. Strings with trim are trimmed first. Numbers that fail Number() 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
  • renderSchemasScript produces window.__wireSchemas = { name: descriptor, … }; to inline in the page.
  • VALIDATE_RUNTIME is a self-contained, eval-free IIFE string. Injected as a <script>, it binds every form[data-schema] and validates on submit and blur, writing messages into [data-error="<field>"] elements and toggling aria-invalid / .wire-invalid. On a valid submit it fetches the form action as JSON (attaching the wire-csrf cookie as an x-csrf-token header), then follows data-redirect / a redirect in the response, surfaces server-side field errors, and fires wire:success / wire:error events. It exposes window.__wireValidate.init(root) and self-initializes on DOMContentLoaded.

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. parseEnv reads Bun.env (falling back to process.env); parseBody and invalid use the Web Request/Response APIs that back Bun.serve.
  • Refinements (refine) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same RuleDescriptor list.
  • 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 injects renderSchemasScript / 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/validation

Example 2

import { v } from "@wrnexus/validation";

Example 3

schema.parse(input: unknown): ParseResult
schema.describe(): SchemaDescriptor

Example 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
}