Files
WRNexusJS/packages/validation/src/index.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

507 lines
17 KiB
TypeScript

/**
* @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/`.
*/
// --- Descriptor (the JSON bridge between server and client) ----------------
export 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 };
export 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[];
}
export interface SchemaDescriptor {
type: "object";
fields: Record<string, FieldDescriptor>;
}
export 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>;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* 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.
*/
export function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null {
switch (rule.kind) {
case "min":
if (type === "string")
return String(value).length < rule.n
? (rule.message ?? `Must be at least ${rule.n} characters`)
: null;
return (value as number) < rule.n ? (rule.message ?? `Must be at least ${rule.n}`) : null;
case "max":
if (type === "string")
return String(value).length > rule.n
? (rule.message ?? `Must be at most ${rule.n} characters`)
: null;
return (value as number) > rule.n ? (rule.message ?? `Must be at most ${rule.n}`) : null;
case "length":
return String(value).length !== rule.n
? (rule.message ?? `Must be exactly ${rule.n} characters`)
: null;
case "email":
return EMAIL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid email");
case "url":
return URL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid URL");
case "uuid":
return UUID_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid UUID");
case "date":
return Number.isNaN(Date.parse(String(value)))
? (rule.message ?? "Must be a valid date")
: null;
case "oneOf":
return rule.values.includes(value as string | number)
? null
: (rule.message ?? `Must be one of: ${rule.values.join(", ")}`);
case "pattern":
try {
return new RegExp(rule.source, rule.flags ?? "").test(String(value))
? null
: (rule.message ?? "Invalid format");
} catch {
return null;
}
case "integer":
return Number.isInteger(value) ? null : (rule.message ?? "Must be a whole number");
default:
return null;
}
}
/** Coerce + validate one field against its descriptor. */
export function checkField(
desc: FieldDescriptor,
raw: unknown,
): { value: unknown; error: string | null } {
if (desc.type === "unknown") {
const empty = raw === undefined || raw === null || raw === "";
return {
value: raw,
error: empty && !desc.optional ? desc.requiredMessage || "Required" : null,
};
}
if (desc.type === "boolean") {
const value = raw === true || raw === "true" || raw === "on";
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 : desc.requiredMessage || "Required",
};
}
let value: unknown;
if (desc.type === "number") {
value = Number(pre);
if (Number.isNaN(value)) return { value, error: desc.typeMessage ?? "Must be a number" };
} else {
value = String(pre);
}
for (const rule of desc.rules) {
const error = applyRule(desc.type, rule, value);
if (error) return { value, error };
}
return { value, error: null };
}
// --- Fluent builder --------------------------------------------------------
/** A server-only refinement (a predicate that can't be serialized to the client). */
type Refinement = { fn: (value: unknown) => boolean | string; message?: string };
export abstract class FieldSchema {
abstract readonly type: "string" | "number" | "boolean" | "unknown";
protected _optional = false;
protected _requiredMessage?: string;
protected _label?: string;
protected _default?: unknown;
protected rules: RuleDescriptor[] = [];
protected refinements: Refinement[] = [];
optional(): this {
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;
}
/** Value used when the field is absent (implies optional). */
default(value: unknown): this {
this._default = value;
this._optional = true;
return this;
}
min(n: number, message?: string): this {
this.rules.push({ kind: "min", n, message });
return this;
}
max(n: number, message?: string): this {
this.rules.push({ kind: "max", n, message });
return 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 {
this.refinements.push({ fn, message });
return this;
}
getDefault(): unknown {
return this._default;
}
runRefinements(value: unknown): string | null {
for (const r of this.refinements) {
const result = r.fn(value);
if (result === false) return r.message ?? "Invalid value";
if (typeof result === "string") return result;
}
return null;
}
describe(): FieldDescriptor {
return {
type: this.type,
optional: this._optional || undefined,
requiredMessage: this._requiredMessage,
label: this._label,
rules: this.rules,
};
}
}
export class StringSchema<TValue extends string = string> extends FieldSchema {
/** Type-only marker used to preserve literal unions through schema inference. */
declare readonly __value: TValue;
readonly type = "string" as const;
private _trim = false;
email(message?: string): this {
this.rules.push({ kind: "email", message });
return this;
}
url(message?: string): this {
this.rules.push({ kind: "url", message });
return this;
}
uuid(message?: string): this {
this.rules.push({ kind: "uuid", message });
return this;
}
date(message?: string): this {
this.rules.push({ kind: "date", message });
return this;
}
length(n: number, message?: string): this {
this.rules.push({ kind: "length", n, message });
return this;
}
oneOf<const TValues extends readonly string[]>(
values: TValues,
message?: string,
): StringSchema<TValues extends readonly [string, ...string[]] ? TValues[number] : TValue> {
this.rules.push({ kind: "oneOf", values: [...values], message });
return this as unknown as StringSchema<
TValues extends readonly [string, ...string[]] ? TValues[number] : TValue
>;
}
trim(): this {
this._trim = true;
return this;
}
pattern(re: RegExp, message?: string): this {
this.rules.push({ kind: "pattern", source: re.source, flags: re.flags, message });
return this;
}
describe(): FieldDescriptor {
return { ...super.describe(), trim: this._trim || undefined };
}
}
export class NumberSchema<TValue extends number = number> extends FieldSchema {
/** Type-only marker used to preserve numeric literal unions through schema inference. */
declare readonly __value: TValue;
readonly type = "number" as const;
integer(message?: string): this {
this.rules.push({ kind: "integer", message });
return this;
}
positive(message?: string): this {
this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
return this;
}
oneOf<const TValues extends readonly number[]>(
values: TValues,
message?: string,
): NumberSchema<TValues extends readonly [number, ...number[]] ? TValues[number] : TValue> {
this.rules.push({ kind: "oneOf", values: [...values], message });
return this as unknown as NumberSchema<
TValues extends readonly [number, ...number[]] ? TValues[number] : TValue
>;
}
}
export class BooleanSchema extends FieldSchema {
readonly type = "boolean" as const;
}
export class UnknownSchema extends FieldSchema {
readonly type = "unknown" as const;
}
export type AnyFieldSchema =
StringSchema<string> | NumberSchema<number> | BooleanSchema | UnknownSchema;
/** Infer the runtime value produced by a field schema. */
export 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. */
export type InferObjectFields<TFields extends Record<string, FieldSchema>> = {
[K in keyof TFields]: InferFieldValue<TFields[K]>;
};
/** Infer the object value produced by an object schema. */
export type InferSchema<TSchema extends ObjectSchema> =
TSchema extends ObjectSchema<infer TValue> ? TValue : never;
export class ObjectSchema<TValue extends object = Record<string, unknown>> {
/** Type-only marker used by helper functions to infer validated output. */
declare readonly __output: TValue;
constructor(private readonly fields: Record<string, FieldSchema>) {}
/** Return a defensive copy of the schema fields. */
getFields(): Readonly<Record<string, FieldSchema>> {
return { ...this.fields };
}
/** 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>> {
return new ObjectSchema({ ...this.fields, ...fields });
}
/** Create a new schema containing fields from both schemas. */
merge<TOther extends object>(schema: ObjectSchema<TOther>): ObjectSchema<TValue & TOther> {
return new ObjectSchema({ ...this.fields, ...schema.getFields() });
}
/** Validate an input object; returns coerced values + per-field errors. */
parse(input: unknown): ParseResult<TValue> {
const source = (input ?? {}) as Record<string, unknown>;
const value: Record<string, unknown> = {};
const errors: Record<string, string> = {};
for (const [name, field] of Object.entries(this.fields)) {
const { value: coerced, error } = checkField(field.describe(), source[name]);
if (error) {
errors[name] = error;
continue;
}
let finalValue = coerced;
if (finalValue === undefined) {
const fallback = field.getDefault();
if (fallback !== undefined) finalValue = fallback;
}
if (finalValue !== undefined) {
const refineError = field.runRefinements(finalValue);
if (refineError) {
errors[name] = refineError;
continue;
}
value[name] = finalValue;
}
}
return { ok: Object.keys(errors).length === 0, value: value as TValue, errors };
}
describe(): SchemaDescriptor {
const fields: Record<string, FieldDescriptor> = {};
for (const [name, field] of Object.entries(this.fields)) fields[name] = field.describe();
return { type: "object", fields };
}
}
/** The fluent schema builder. */
export const v = {
string: () => new StringSchema<string>(),
number: () => new NumberSchema<number>(),
boolean: () => new BooleanSchema(),
unknown: () => new UnknownSchema(),
object: <TFields extends Record<string, FieldSchema>>(fields: TFields) =>
new ObjectSchema<InferObjectFields<TFields>>(fields),
};
// --- Environment configuration --------------------------------------------
/** Read the ambient environment (Bun.env, falling back to process.env). */
function readEnv(): Record<string, string | undefined> {
const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
if (bun?.env) return bun.env;
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
return proc?.env ?? {};
}
/**
* 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(),
* }));
*/
export function parseEnv<T extends object>(
schema: ObjectSchema<T>,
source: Record<string, string | undefined> = readEnv(),
): T {
const result = schema.parse(source);
if (!result.ok) {
const lines = Object.entries(result.errors).map(([name, message]) => ` • ${name}: ${message}`);
throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
}
return result.value;
}
// --- API helpers -----------------------------------------------------------
/** A 400 response carrying field errors, for API routes. */
export function invalid(errors: Record<string, string>): Response {
return Response.json({ ok: false, errors }, { status: 400 });
}
/**
* Parse a request's JSON body against a schema. On failure returns
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
*/
export async function parseBody<T extends object>(
schema: ObjectSchema<T>,
req: Request,
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
const body = await readBody(req);
const result = schema.parse(body);
if (!result.ok) return { ok: false, response: invalid(result.errors) };
return { ok: true, value: result.value };
}
/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
async function readBody(req: Request): Promise<Record<string, unknown>> {
const contentType = req.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
return (await req.json()) as Record<string, unknown>;
}
if (
contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data")
) {
const out: Record<string, unknown> = {};
for (const [key, value] of await req.formData()) out[key] = value;
return out;
}
// Best effort: try JSON, else treat as empty.
return (await req.json()) as Record<string, unknown>;
} catch {
return {};
}
}
export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts";
export {
AsyncObjectSchema,
asyncSchema,
parseBodyAsync,
schemaToOpenApi,
mergeValidationResults,
} from "./advanced.ts";
export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";
export {
ValidationError,
parseOrThrow,
validationResponse,
firstValidationError,
validationSummary,
schemaFieldNames,
} from "./helpers.ts";
export { validationPlugin, validationComponentsDir } from "./plugin.ts";
export type { ValidationPluginOptions } from "./plugin.ts";
export {
toJsonSchema,
openApiRequestBody,
localizeDescriptor,
parseDescriptor,
} from "./interop.ts";
export type {
JsonSchemaDocument,
ValidationMessageKey,
ValidationMessageTranslator,
} from "./interop.ts";
export {
ContractRegistry,
checkContractCompatibility,
defineContract,
defineEvent,
} from "./contracts.ts";
export type {
ContractDefinition,
ContractIssue,
ContractKind,
ContractRecord,
ContractSnapshot,
} from "./contracts.ts";