feat: support custom required validation messages

This commit is contained in:
2026-07-13 20:34:36 +05:30
parent b273e30c2f
commit 7a1c6c0041
58 changed files with 169 additions and 93 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.2.22",
"@wrnexus/router": "^0.2.22",
"@wrnexus/csr": "^0.2.22",
"@wrnexus/compiler": "^0.2.22",
"@wrnexus/styles": "^0.2.22",
"@wrnexus/dev-server": "^0.2.22",
"@wrnexus/ui": "^0.2.22",
"@wrnexus/validation": "^0.2.22",
"@wrnexus/i18n": "^0.2.22",
"@wrnexus/db": "^0.2.22"
"@wrnexus/core": "^0.2.23",
"@wrnexus/router": "^0.2.23",
"@wrnexus/csr": "^0.2.23",
"@wrnexus/compiler": "^0.2.23",
"@wrnexus/styles": "^0.2.23",
"@wrnexus/dev-server": "^0.2.23",
"@wrnexus/ui": "^0.2.23",
"@wrnexus/validation": "^0.2.23",
"@wrnexus/i18n": "^0.2.23",
"@wrnexus/db": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22",
"@wrnexus/router": "^0.2.22",
"@wrnexus/ssr": "^0.2.22",
"@wrnexus/csr": "^0.2.22",
"@wrnexus/compiler": "^0.2.22",
"@wrnexus/styles": "^0.2.22",
"@wrnexus/ui": "^0.2.22",
"@wrnexus/validation": "^0.2.22",
"@wrnexus/i18n": "^0.2.22",
"@wrnexus/db": "^0.2.22",
"@wrnexus/pubsub": "^0.2.22",
"@wrnexus/uploader": "^0.2.22"
"@wrnexus/core": "^0.2.23",
"@wrnexus/router": "^0.2.23",
"@wrnexus/ssr": "^0.2.23",
"@wrnexus/csr": "^0.2.23",
"@wrnexus/compiler": "^0.2.23",
"@wrnexus/styles": "^0.2.23",
"@wrnexus/ui": "^0.2.23",
"@wrnexus/validation": "^0.2.23",
"@wrnexus/i18n": "^0.2.23",
"@wrnexus/db": "^0.2.23",
"@wrnexus/pubsub": "^0.2.23",
"@wrnexus/uploader": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.2.22"
"@wrnexus/native": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.2.22",
"@wrnexus/core": "^0.2.22"
"@wrnexus/compiler": "^0.2.23",
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.2.22"
"@wrnexus/uploader": "^0.2.23"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.22"
"@wrnexus/core": "^0.2.23"
},
"files": [
"dist"
+3 -2
View File
@@ -35,6 +35,7 @@ import { v } from "@wrnexus/validation";
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 (implies `optional`).
@@ -121,8 +122,8 @@ 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),
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(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -280,6 +280,14 @@ const MIGRATIONS: Migration[] = [
// Explicit no-op: updating @wrnexus/dev-server and helpers is sufficient.
},
},
{
version: "0.2.23",
id: "custom-required-validation-message",
description: "No project-file changes; custom required messages are provided at runtime",
apply() {
// Explicit no-op: updating @wrnexus/validation is sufficient.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.22",
"version": "0.2.23",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+3 -2
View File
@@ -35,6 +35,7 @@ import { v } from "@wrnexus/validation";
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 (implies `optional`).
@@ -121,8 +122,8 @@ 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),
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(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"type": "module",
"main": "src/index.ts",
+19 -2
View File
@@ -22,6 +22,8 @@ export type RuleDescriptor =
export interface FieldDescriptor {
type: "string" | "number" | "boolean";
optional?: boolean;
/** Message used when a required field is empty. Defaults to "Required". */
requiredMessage?: string;
label?: string;
/** Trim string input before validating. */
trim?: boolean;
@@ -103,13 +105,20 @@ export function checkField(
): { value: unknown; error: string | null } {
if (desc.type === "boolean") {
const value = raw === true || raw === "true" || raw === "on";
if (!desc.optional && !value) return { value, error: "Required" };
if (!desc.optional && !value) {
return { value, error: desc.requiredMessage || "Required" };
}
return { value, error: null };
}
const pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
const empty = pre === undefined || pre === null || pre === "";
if (empty) return { value: undefined, error: desc.optional ? null : "Required" };
if (empty) {
return {
value: undefined,
error: desc.optional ? null : desc.requiredMessage || "Required",
};
}
let value: unknown;
if (desc.type === "number") {
@@ -133,6 +142,7 @@ type Refinement = { fn: (value: unknown) => boolean | string; message?: string }
abstract class FieldSchema {
abstract readonly type: "string" | "number" | "boolean";
protected _optional = false;
protected _requiredMessage?: string;
protected _label?: string;
protected _default?: unknown;
protected rules: RuleDescriptor[] = [];
@@ -142,6 +152,12 @@ abstract class FieldSchema {
this._optional = true;
return this;
}
/** Require a non-empty value and optionally replace the default message. */
required(message = "Required"): this {
this._optional = false;
this._requiredMessage = message;
return this;
}
label(label: string): this {
this._label = label;
return this;
@@ -183,6 +199,7 @@ abstract class FieldSchema {
return {
type: this.type,
optional: this._optional || undefined,
requiredMessage: this._requiredMessage,
label: this._label,
rules: this.rules,
};
+2 -2
View File
@@ -42,11 +42,11 @@ export const VALIDATE_RUNTIME = String.raw`
function checkField(desc, raw) {
if (desc.type === "boolean") {
var b = raw === true || raw === "true" || raw === "on";
return (!desc.optional && !b) ? "Required" : null;
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
}
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
var empty = pre === undefined || pre === null || pre === "";
if (empty) return desc.optional ? null : "Required";
if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
var value;
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
else value = String(pre);
+50 -1
View File
@@ -1,5 +1,6 @@
import { test, expect } from "bun:test";
import { v, invalid, parseBody, checkField, parseEnv } from "../src/index.ts";
import { Window } from "happy-dom";
import { v, invalid, parseBody, checkField, parseEnv, VALIDATE_RUNTIME } from "../src/index.ts";
const login = v.object({
email: v.string().email(),
@@ -33,6 +34,51 @@ test("required (non-optional) empty fields fail", () => {
expect(r.errors.password).toBe("Required");
});
test("required(message) customizes empty-field errors and overrides optional", () => {
const schema = v.object({
email: v.string().required("Enter your email or username"),
password: v.string().optional().required("Enter your password"),
accepted: v.boolean().required("Accept the terms to continue"),
});
const result = schema.parse({ accepted: false });
expect(result.errors).toEqual({
email: "Enter your email or username",
password: "Enter your password",
accepted: "Accept the terms to continue",
});
expect(schema.describe().fields.email.requiredMessage).toBe("Enter your email or username");
});
test("browser validation displays the serialized custom required message", () => {
const schema = v.object({
email: v.string().required("Enter your email or username"),
});
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="login">
<input name="email">
<span data-error="email"></span>
</form>`;
win.__wireSchemas = { login: schema.describe() };
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
try {
(0, eval)(VALIDATE_RUNTIME);
const runtime = win.__wireValidate as { init(root: Document): void };
runtime.init(win.document as unknown as Document);
win.document.querySelector("input")!.dispatchEvent(new win.Event("blur", { bubbles: true }));
expect(win.document.querySelector("[data-error=email]")!.textContent).toBe(
"Enter your email or username",
);
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("describe emits a JSON descriptor", () => {
const d = login.describe();
expect(d.type).toBe("object");
@@ -46,6 +92,9 @@ test("checkField coerces and applies rules", () => {
);
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "20").value).toBe(20);
expect(checkField({ type: "string", optional: true, rules: [] }, "").error).toBeNull();
expect(
checkField({ type: "string", requiredMessage: "Email is required", rules: [] }, "").error,
).toBe("Email is required");
});
test("invalid() returns a 400 with errors", async () => {