@wrnexus/validation
Typed schemas, coercion, validation, and browser descriptors.
Install the package
After WorkRoot approves private registry access, install the release-aligned package:
bun add @wrnexus/validation@0.8.7Request preview access. Never put registry tokens in source control.
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.
Boundary contracts
Use ContractRegistry with defineContract or defineEvent to publish the same schema descriptors for APIs, actions, webhooks, realtime, queues, cron, pub/sub, plugins, configuration, and environment variables.
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";
export const contracts = new ContractRegistry().register(
defineEvent({
name: "user.created",
version: 1,
consumers: ["notification-worker", "audit-service"],
payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
}),
);
Export the registry from app/contracts.ts, then accept a baseline with wrnexus contracts snapshot. CI can run wrnexus contracts check; removed contracts/fields, required-field additions, type changes, narrowed enums, and tighter validation fail with stable WRN-CONTRACT-* diagnostics and list known consumers. A generated wrnexus.contracts.json can be used instead of a module.
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/.
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.required(message?)— require a non-empty value and optionally replace the default"Required"message on both server and browser validation.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().required("Enter your email address").trim().email(),
password: v.string().required("Enter your password").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.
Helper and component kit
The public helper API includes parseOrThrow, ValidationError, validationResponse, firstValidationError, validationSummary, and schemaFieldNames.
Schema output is inferred automatically by ObjectSchema, parseOrThrow, parseBody, parseEnv, and asyncSchema. Use InferSchema<typeof schema> when a named output type is useful:
const accountSchema = v.object({
email: v.string().email(),
attempts: v.number().integer(),
});
type AccountInput = InferSchema<typeof accountSchema>;
const account = parseOrThrow(accountSchema, input);
// account.email: string
// account.attempts: number
Enable validationPlugin() for:
<ValidationSummary /><FieldError />
The summary block composes Alert from @wrnexus/ui, while FieldError remains a lightweight accessible field-level primitive. Schemas can drive external contracts without maintaining a second definition:
import {
localizeDescriptor,
openApiRequestBody,
parseDescriptor,
toJsonSchema,
} from "@wrnexus/validation";
const jsonSchema = toJsonSchema(contactSchema, {
id: "urn:example:contact",
title: "Contact request",
});
const requestBody = openApiRequestBody(contactSchema);
const mr = localizeDescriptor(contactSchema, (key, params) =>
translations.t(`validation.${key}`, params),
);
const result = parseDescriptor(mr, input);
JSON Schema output targets draft 2020-12, closes unknown object properties, and maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies reuse the same properties. Localized descriptors preserve explicit custom messages and fill default required, type-coercion, and rule messages; the same descriptor is consumable by server parsing and the eval-free browser runtime.
Complete TypeScript API
Generated from the exact installed package declarations.
export { ValidationPluginOptions, validationComponentsDir, default as validationPlugin } from './plugin.js';
import '@wrnexus/plugin';
/**
* 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;
interface AsyncValidationContext<T> {
value: T;
addIssue(field: keyof T | string, message: string): void;
signal?: AbortSignal;
}
type AsyncRefinement<T> = (context: AsyncValidationContext<T>) => void | Promise<void>;
declare class AsyncObjectSchema<T extends object = Record<string, unknown>> {
#private;
readonly base: ObjectSchema<T>;
constructor(base: ObjectSchema<T>);
refine(refinement: AsyncRefinement<T>): this;
describe(): SchemaDescriptor;
parse(input: unknown, signal?: AbortSignal): Promise<ParseResult<T>>;
}
declare function asyncSchema<T extends object>(schema: ObjectSchema<T>): AsyncObjectSchema<T>;
declare function parseBodyAsync<T extends object>(schema: AsyncObjectSchema<T>, request: Request, signal?: AbortSignal): Promise<{
ok: true;
value: T;
} | {
ok: false;
response: Response;
}>;
interface OpenApiSchema {
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
}
declare function schemaToOpenApi(schema: ObjectSchema | AsyncObjectSchema): OpenApiSchema;
declare function mergeValidationResults<T>(...results: ParseResult<T>[]): ParseResult<T>;
declare class ValidationError<T = Record<string, unknown>> extends Error {
readonly result: ParseResult<T>;
constructor(result: ParseResult<T>);
}
declare function parseOrThrow<T extends object>(schema: ObjectSchema<T>, input: unknown): T;
declare function validationResponse(result: ParseResult, options?: {
successStatus?: number;
failureStatus?: number;
}): Response;
declare function firstValidationError(errors: Record<string, string>): string | null;
declare function validationSummary(errors: Record<string, string>): Array<{
field: string;
message: string;
}>;
declare function schemaFieldNames(schema: ObjectSchema | SchemaDescriptor): string[];
interface JsonSchemaDocument {
$schema: "https://json-schema.org/draft/2020-12/schema";
$id?: string;
title?: string;
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
additionalProperties: false;
}
declare function toJsonSchema(schema: ObjectSchema | SchemaDescriptor, options?: {
id?: string;
title?: string;
}): JsonSchemaDocument;
declare function openApiRequestBody(schema: ObjectSchema | SchemaDescriptor, options?: {
description?: string;
required?: boolean;
contentTypes?: string[];
}): {
required: boolean;
content: {
[k: string]: {
schema: {
$id?: string;
title?: string;
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
additionalProperties: false;
};
};
};
description?: string | undefined;
};
type ValidationMessageKey = "required" | "number" | `rule.${RuleDescriptor["kind"]}`;
type ValidationMessageTranslator = (key: ValidationMessageKey, params: Record<string, unknown>) => string;
declare function localizeDescriptor(schema: ObjectSchema | SchemaDescriptor, translate: ValidationMessageTranslator): SchemaDescriptor;
declare function parseDescriptor<T = Record<string, unknown>>(descriptor: SchemaDescriptor, source: Record<string, unknown>): ParseResult<T>;
type ContractKind = "api" | "action" | "webhook" | "realtime" | "queue" | "cron" | "pubsub" | "plugin" | "config" | "env";
interface ContractDefinition<T extends object = Record<string, unknown>> {
kind: ContractKind;
name: string;
version: number;
payload: ObjectSchema<T> | SchemaDescriptor;
consumers?: string[];
description?: string;
}
interface ContractRecord {
kind: ContractKind;
name: string;
version: number;
payload: SchemaDescriptor;
consumers: string[];
description?: string;
}
interface ContractSnapshot {
format: 1;
contracts: ContractRecord[];
}
interface ContractIssue {
code: "WRN-CONTRACT-REMOVED" | "WRN-CONTRACT-FIELD-REMOVED" | "WRN-CONTRACT-FIELD-REQUIRED" | "WRN-CONTRACT-FIELD-TYPE" | "WRN-CONTRACT-RULE-TIGHTENED";
contract: string;
field?: string;
message: string;
consumers: string[];
}
declare function defineContract<T extends object>(definition: ContractDefinition<T>): ContractDefinition<T>;
declare function defineEvent<T extends object>(definition: Omit<ContractDefinition<T>, "kind"> & {
kind?: "realtime" | "pubsub";
}): ContractDefinition<T>;
declare class ContractRegistry {
private readonly records;
register<T extends object>(definition: ContractDefinition<T>): this;
snapshot(): ContractSnapshot;
}
declare function checkContractCompatibility(previous: ContractSnapshot, current: ContractSnapshot): ContractIssue[];
/**
* @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" | "unknown";
optional?: boolean;
/** Message used when a required field is empty. Defaults to "Required". */
requiredMessage?: string;
/** Message used when coercion to the declared type fails. */
typeMessage?: string;
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" | "unknown";
protected _optional: boolean;
protected _requiredMessage?: string;
protected _label?: string;
protected _default?: unknown;
protected rules: RuleDescriptor[];
protected refinements: Refinement[];
optional(): this;
/** Require a non-empty value and optionally replace the default message. */
required(message?: string): 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<TValue extends string = string> extends FieldSchema {
/** Type-only marker used to preserve literal unions through schema inference. */
readonly __value: TValue;
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<const TValues extends readonly string[]>(values: TValues, message?: string): StringSchema<TValues extends readonly [string, ...string[]] ? TValues[number] : TValue>;
trim(): this;
pattern(re: RegExp, message?: string): this;
describe(): FieldDescriptor;
}
declare class NumberSchema<TValue extends number = number> extends FieldSchema {
/** Type-only marker used to preserve numeric literal unions through schema inference. */
readonly __value: TValue;
readonly type: "number";
integer(message?: string): this;
positive(message?: string): this;
oneOf<const TValues extends readonly number[]>(values: TValues, message?: string): NumberSchema<TValues extends readonly [number, ...number[]] ? TValues[number] : TValue>;
}
declare class BooleanSchema extends FieldSchema {
readonly type: "boolean";
}
declare class UnknownSchema extends FieldSchema {
readonly type: "unknown";
}
type AnyFieldSchema = StringSchema<string> | NumberSchema<number> | BooleanSchema | UnknownSchema;
/** Infer the runtime value produced by a field schema. */
type InferFieldValue<TField extends FieldSchema> = TField extends StringSchema<infer TValue> ? TValue : TField extends NumberSchema<infer TValue> ? TValue : TField extends BooleanSchema ? boolean : unknown;
/** Infer the validated object produced by a field map. */
type InferObjectFields<TFields extends Record<string, FieldSchema>> = {
[K in keyof TFields]: InferFieldValue<TFields[K]>;
};
/** Infer the object value produced by an object schema. */
type InferSchema<TSchema extends ObjectSchema> = TSchema extends ObjectSchema<infer TValue> ? TValue : never;
declare class ObjectSchema<TValue extends object = Record<string, unknown>> {
private readonly fields;
/** Type-only marker used by helper functions to infer validated output. */
readonly __output: TValue;
constructor(fields: Record<string, FieldSchema>);
/** Return a defensive copy of the schema fields. */
getFields(): Readonly<Record<string, FieldSchema>>;
/** Create a new schema with fields added or replaced. The original is unchanged. */
extend<TFields extends Record<string, FieldSchema>>(fields: TFields): ObjectSchema<Omit<TValue, keyof TFields> & InferObjectFields<TFields>>;
/** Create a new schema containing fields from both schemas. */
merge<TOther extends object>(schema: ObjectSchema<TOther>): ObjectSchema<TValue & TOther>;
/** Validate an input object; returns coerced values + per-field errors. */
parse(input: unknown): ParseResult<TValue>;
describe(): SchemaDescriptor;
}
/** The fluent schema builder. */
declare const v: {
string: () => StringSchema<string>;
number: () => NumberSchema<number>;
boolean: () => BooleanSchema;
unknown: () => UnknownSchema;
object: <TFields extends Record<string, FieldSchema>>(fields: TFields) => ObjectSchema<InferObjectFields<TFields>>;
};
/**
* 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 extends object>(schema: ObjectSchema<T>, 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 extends object>(schema: ObjectSchema<T>, req: Request): Promise<{
ok: true;
value: T;
} | {
ok: false;
response: Response;
}>;
export { type AnyFieldSchema, AsyncObjectSchema, type AsyncRefinement, type AsyncValidationContext, BooleanSchema, type ContractDefinition, type ContractIssue, type ContractKind, type ContractRecord, ContractRegistry, type ContractSnapshot, type FieldDescriptor, FieldSchema, type InferFieldValue, type InferObjectFields, type InferSchema, type JsonSchemaDocument, NumberSchema, ObjectSchema, type OpenApiSchema, type ParseResult, type RuleDescriptor, type SchemaDescriptor, StringSchema, UnknownSchema, VALIDATE_RUNTIME, ValidationError, type ValidationMessageKey, type ValidationMessageTranslator, applyRule, asyncSchema, checkContractCompatibility, checkField, defineContract, defineEvent, firstValidationError, invalid, localizeDescriptor, mergeValidationResults, openApiRequestBody, parseBody, parseBodyAsync, parseDescriptor, parseEnv, parseOrThrow, renderSchemasScript, schemaFieldNames, schemaToOpenApi, toJsonSchema, v, validationResponse, validationSummary };
Examples
Copy-ready examples from the installed package documentation.
Define a schema and validate an API body
import { v, parseBody } from "@wrnexus/validation";
export const signupSchema = v.object({
email: v.string().required("Enter your email address").trim().email(),
password: v.string().required("Enter your password").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 misconfiguredWire 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.