@valfuse-node/form
v0.3.5
Published
Framework-agnostic schema validation, transformations, and form-state primitives
Maintainers
Readme
@valfuse-node/form
Framework-agnostic schema validation and form-state primitives. Reuse schemas in React and Vue forms, Express REST middleware, Node.js server actions, or CLI tools.
npm install @valfuse-node/formThe published package declares Node.js 22 or newer in engines. It has no
React, Vue, or other runtime framework peer dependency. Its schema and
validation APIs can be used directly on server-side data or by the Express
adapter; its form-state primitives support framework adapters and applications.
Table of Contents
- Why
@valfuse-node/form? - Quick Start
- Schema Definition
- Built-in Rules
- Value Transformation
- Validation
- Error Handling
- Framework-Agnostic State
- Type Reference
- Development Usage
- Release history
- Support
- License
Why @valfuse-node/form?
The form package is the shared contract for the entire @valfuse-node ecosystem:
- Use directly in Node.js / server actions — no React or Vue required.
- Use as the validation engine for
useReactValfuseForm/useVueValfuseForm— the adapters read the sameValfuseSchemaandValfuseFormStateinterfaces. - Use with Express REST APIs through
@valfuse-node/expressmiddleware for request bodies, route params, and query values. It transforms and validates parsed request data using the same schema. - Type-safe end-to-end —
defaultValuesinfers the value type;setErrorsis typed by your field names;formState.errorsis aRecord<keyof TFieldValues, ValfuseFieldError>.
import { createSchema, validateSchema, transformValues, t } from "@valfuse-node/form";See the Express adapter guide for REST middleware examples using the same schema API.
Quick Start
import { createSchema, validateSchema, transformValues, t } from "@valfuse-node/form";
const schema = createSchema({
email: {
type: "string",
transform: t.pipe(t.trim, t.toLowerCase),
rules: [
{ name: "required", error: { message: "Email is required", code: "email.required" } },
{ name: "email", error: { message: "Invalid email", code: "email.invalid" } },
],
},
age: {
type: "number",
transform: t.toInteger,
rules: [
{ name: "required", error: { message: "Required", code: "age.required" } },
{ name: "min", value: 18, error: { message: "Must be 18+", code: "age.min" } },
],
},
});
// 1. Coerce raw input (e.g. strings from <input>) to typed values
const typed = transformValues(schema, { email: " [email protected] ", age: "25" });
// → { email: "[email protected]", age: 25 }
// 2. Validate the typed values
const errors = validateSchema(schema, typed);
// → {} (passes)
// 3. Inject server errors if any
// form.setErrors({ email: { message: "Already registered", code: "auth.duplicate" } });Schema Definition
createSchema(definition) is an identity function whose only job is to give you autocomplete and type inference for the schema.
import { createSchema } from "@valfuse-node/form";
const schema = createSchema({
// ─── string fields ──────────────────────────────────────────────────────────
name: {
type: "string",
rules: [
{ name: "required", error: { message: "Required" } },
{ name: "minLength", value: 2, error: { message: "Min 2 chars" } },
{ name: "maxLength", value: 50, error: { message: "Max 50 chars" } },
],
},
// ─── number fields ──────────────────────────────────────────────────────────
age: {
type: "number",
transform: t.toInteger, // optional pre-validation coercion
rules: [
{ name: "min", value: 0, error: { message: "Must be ≥ 0" } },
{ name: "max", value: 120, error: { message: "Must be ≤ 120" } },
{ name: "int", error: { message: "Whole numbers only" } },
],
},
// ─── boolean fields ─────────────────────────────────────────────────────────
agreed: {
type: "boolean",
rules: [{ name: "accepted", error: { message: "You must accept the terms" } }],
},
// ─── array fields ───────────────────────────────────────────────────────────
tags: {
type: "array",
rules: [
{ name: "minItems", value: 1, error: { message: "Add at least 1 tag" } },
{ name: "maxItems", value: 10, error: { message: "Max 10 tags" } },
],
},
// ─── object fields (nested) ─────────────────────────────────────────────────
address: {
type: "object",
rules: [{ name: "required", error: { message: "Address is required" } }],
},
});Supported field types
| Type | Description |
| ----------- | --------------------------------------------------------------------------- |
| "string" | Free text, validated by string rules |
| "number" | Coerced numeric value (use transform: t.toNumber to coerce raw strings) |
| "boolean" | true / false |
| "array" | Any array (length-based rules only — element validation is a custom rule) |
| "object" | Any object (shape and presence rules only) |
Built-in Rules
String rules
| Rule | Value | Example |
| ------------ | -------------------------------- | ------------------------------------------------------ |
| required | — | { name: "required", error: { message: "Required" } } |
| min | number (length) | { name: "min", value: 3, error: … } |
| max | number (length) | { name: "max", value: 100, error: … } |
| length | number (exact length) | { name: "length", value: 10, error: … } |
| email | — | { name: "email", error: … } |
| url | — | { name: "url", error: … } |
| uuid | — | { name: "uuid", error: … } |
| regex | RegExp or { pattern, flags } | { name: "regex", value: /^[a-z]+$/, error: … } |
| includes | string | { name: "includes", value: "@", error: … } |
| startsWith | string | { name: "startsWith", value: "https://", error: … } |
| endsWith | string | { name: "endsWith", value: ".com", error: … } |
Number rules
| Rule | Value | Notes |
| ------------- | -------- | ---------------------------------- |
| required | — | Rejects null, undefined, NaN |
| min | number | Inclusive lower bound |
| max | number | Inclusive upper bound |
| gt | number | Strictly greater than |
| gte | number | Greater than or equal |
| lt | number | Strictly less than |
| lte | number | Less than or equal |
| int | — | Rejects non-integers |
| positive | — | > 0 |
| nonnegative | — | ≥ 0 |
| negative | — | < 0 |
| nonpositive | — | ≤ 0 |
| multipleOf | number | value % multipleOf === 0 |
Boolean rules
| Rule | Value | Notes |
| ---------- | --------- | ---------------------------------------------------- |
| required | — | Rejects null, undefined, false |
| literal | boolean | Must match exactly |
| accepted | — | Sugar for literal: true (terms-of-service pattern) |
Array rules
| Rule | Value |
| ---------- | ----------------------- |
| required | — |
| min | number (min length) |
| max | number (max length) |
| length | number (exact length) |
| nonempty | — (length ≥ 1) |
Object rules
| Rule | Value |
| ---------- | ---------------------------------------------- |
| required | — (rejects null / undefined) |
| shape | Record<string, unknown> (key set must match) |
Generic (all types)
| Rule | Shape | Use |
| ------------ | ------------------------------------------------------------ | -------------------------------------------------- |
| custom | { name: "custom", validate: (v, all) => boolean, error } | Ad-hoc validator with access to all values |
| refine | Same as custom | Alias — same implementation, different intent name |
| matchField | { name: "matchField", value: "<other-field-name>", error } | Cross-field equality (e.g. password confirmation) |
| oneOf | { name: "oneOf", value: unknown[], error } | Value must be in the list |
| notOneOf | { name: "notOneOf", value: unknown[], error } | Value must NOT be in the list |
Example — cross-field password match:
const schema = createSchema({
password: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
confirmPassword: {
type: "string",
rules: [
{ name: "matchField", value: "password", error: { message: "Passwords do not match" } },
],
},
});Example — custom rule with access to sibling values:
const schema = createSchema({
startDate: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
endDate: {
type: "string",
rules: [
{
name: "custom",
validate: (value, all) => new Date(value as string) > new Date(all.startDate as string),
error: { message: "End date must be after start date" },
},
],
},
});Value Transformation
transform runs before validation and before submission. It is a single function or a t.pipe(...) composition.
Built-in transformers (t)
import { t } from "@valfuse-node/form";
// ─── String transformers ──────────────────────────────────────────────────────
t.trim; // " hi " → "hi"
t.trimStart; // " hi " → "hi "
t.trimEnd; // " hi " → " hi"
t.toLowerCase; // "Hi" → "hi"
t.toUpperCase; // "hi" → "HI"
t.toTitleCase; // "hello world" → "Hello World"
t.toSentenceCase; // "HELLO" → "Hello"
t.collapseSpaces; // "a b" → "a b"
// ─── Coercion transformers ───────────────────────────────────────────────────
t.toNumber; // "42" → 42 (returns original if NaN)
t.toInteger; // "42.7" → 42
t.toFloat; // "3.14" → 3.14
t.toBoolean; // "true"/"1"/1/true → true; everything else → false
// ─── Composition ──────────────────────────────────────────────────────────────
t.pipe(t.trim, t.toLowerCase); // compose left-to-rightCustom transformers
Any function (value: unknown) => unknown is a valid transformer:
const slugify = (v: unknown) => (typeof v === "string" ? v.toLowerCase().replace(/\s+/g, "-") : v);
const schema = createSchema({
slug: {
type: "string",
transform: slugify,
rules: [{ name: "required", error: { message: "Required" } }],
},
});transformValues(schema, values)
Apply all per-field transforms in one call — the canonical pre-submit pipeline:
import { transformValues } from "@valfuse-node/form";
const typed = transformValues(schema, { email: " [email protected] ", age: "25" });
// → { email: "[email protected]", age: 25 }Fields without a transform are passed through unchanged. The original values object is never mutated.
Validation
import { validateSchema } from "@valfuse-node/form";
const errors = validateSchema(loginSchema, {
email: "bad",
password: "123",
});
// → {
// email: { message: "Invalid email format", type: "validation", code: "email.invalid" },
// password: { message: "Min 8 chars", type: "validation", code: "password.min" },
// }Returns: Record<string, ValfuseError>. Empty object {} means valid.
Error shape:
interface ValfuseError {
message: string; // user-facing message
type?: "validation" | "server" | "manual" | "custom";
code?: string; // semantic code (e.g. "email.required")
metadata?: Record<string, unknown>; // extra context
}Note:
validateSchemareturns the first error per field (rules are evaluated in order, and validation short-circuits on the first error). Order your rules from cheapest → most expensive.
Error Handling
normalizeError(error)
import { normalizeError } from "@valfuse-node/form";
normalizeError("Something went wrong");
// → { message: "Something went wrong" }
normalizeError({ message: "Boom", code: "boom.explode" });
// → { message: "Boom", code: "boom.explode" }Useful when you need to merge API errors (which may be strings or objects) into the same shape your form expects.
ValfuseFieldError (the shape used by adapters)
interface ValfuseFieldError {
message: string;
type?: string; // "validation" | "server" | "manual" | "custom"
code?: string; // e.g. "email.required", "auth.not_found"
metadata?: Record<string, unknown>;
}Error types by origin
| type | Origin | Typical use |
| -------------- | ----------------------------------------------------- | ------------------------------------------------------------ |
| "validation" | A schema rule failed | Automatic — emitted by validateSchema |
| "server" | Injected via form.setErrors after a failed API call | Manual |
| "manual" | Injected via form.setErrors for client-only logic | Manual |
| "custom" | Returned by a custom / refine rule | Automatic — but tagged "custom" so consumers can distinguish |
Framework-Agnostic State
If you want the same form-state primitives the React/Vue adapters use internally, you can import them directly. Most consumers will not need this — use useReactValfuseForm or useVueValfuseForm instead. This is exposed for adapter authors and for non-framework usage (e.g. CLI tools, server actions).
Values
import {
createValuesState,
updateValue,
resetValues,
computeIsDirty,
computeDirtyFields,
} from "@valfuse-node/form";
const state = createValuesState({ email: "", age: 0 });
updateValue(state, "email", "[email protected]");
const isDirty = computeIsDirty(state, { email: "", age: 0 });
// → true
const dirty = computeDirtyFields(state, { email: "", age: 0 });
// → { email: true }
resetValues(state, { email: "", age: 0 });Touched
import {
createTouchedState,
markTouched,
isTouched,
toTouchedFieldsRecord,
} from "@valfuse-node/form";
const touched = createTouchedState();
markTouched(touched, "email");
isTouched(touched, "email"); // true
toTouchedFieldsRecord(touched); // { email: true }Errors
import {
createErrorsState,
setFieldError,
clearFieldErrors,
hasErrors,
getFieldError,
toFormErrors,
} from "@valfuse-node/form";
const errors = createErrorsState();
setFieldError(errors, "email", { message: "Taken", code: "auth.duplicate" });
hasErrors(errors); // true
getFieldError(errors, "email"); // { message: "Taken", code: "auth.duplicate" }
clearFieldErrors(errors);
toFormErrors(errors); // {} (object form)Submission
import {
createSubmissionState,
startSubmit,
endSubmitSuccess,
endSubmitFailure,
resetSubmission,
} from "@valfuse-node/form";
const sub = createSubmissionState();
startSubmit(sub);
try {
await api.call();
endSubmitSuccess(sub);
} catch (err) {
endSubmitFailure(sub);
} finally {
// sub.isSubmitting === false
}Type Reference
ValfuseSchema
type ValfuseSchema = Record<string, ValfuseFieldSchema>;
type ValfuseFieldSchema =
| ValfuseStringFieldSchema
| ValfuseNumberFieldSchema
| ValfuseBooleanFieldSchema
| ValfuseArrayFieldSchema
| ValfuseObjectFieldSchema;
interface ValfuseStringFieldSchema {
type: "string";
rules: (ValfuseStringRule | ValfuseGenericRule)[];
transform?: ValfuseTransformer;
}
// (same shape for the other four types)Rule types
Every rule is a discriminated union member with a discriminator field. The TypeScript type for each field's rules array is the union of type-specific rules plus the generic ones.
// Generic (work on any field type)
type ValfuseGenericRule =
| { name: "custom"; validate: (v, all) => boolean; error: ValfuseRuleError }
| { name: "refine"; validate: (v, all) => boolean; error: ValfuseRuleError }
| { name: "matchField"; value: string; error: ValfuseRuleError }
| { name: "oneOf"; value: unknown[]; error: ValfuseRuleError }
| { name: "notOneOf"; value: unknown[]; error: ValfuseRuleError };Error types
type ValfuseErrorType = "validation" | "server" | "manual" | "custom";
interface ValfuseError {
message: string;
type?: ValfuseErrorType | string;
code?: string;
metadata?: Record<string, unknown>;
}
type ValfuseFieldErrors<TFieldName extends string = string> = Partial<
Record<TFieldName, string | ValfuseError>
>;Development Usage
Use it in a Node.js script
// scripts/validate-signup.ts
import { createSchema, validateSchema, transformValues } from "@valfuse-node/form";
const schema = createSchema({
email: {
type: "string",
transform: (v) => String(v).toLowerCase(),
rules: [{ name: "required", error: { message: "Required" } }],
},
});
const input = process.argv[2] ?? "";
const errors = validateSchema(schema, transformValues(schema, { email: input }));
if (Object.keys(errors).length) {
console.error("Invalid:", errors);
process.exit(1);
}
console.log("OK");npx tsx scripts/validate-signup.ts "[email protected]"Use it in a server action
// app/actions/signup.ts
"use server";
import { createSchema, validateSchema, transformValues, normalizeError } from "@valfuse-node/form";
const schema = createSchema({
email: {
type: "string",
rules: [
{ name: "required", error: { message: "Email required" } },
{ name: "email", error: { message: "Invalid" } },
],
},
password: {
type: "string",
rules: [
{ name: "required", error: { message: "Password required" } },
{ name: "minLength", value: 8, error: { message: "Min 8" } },
],
},
});
export async function signupAction(formData: FormData) {
const typed = transformValues(schema, {
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
});
const errors = validateSchema(schema, typed);
if (Object.keys(errors).length) {
return { ok: false, errors };
}
// … DB insert
return { ok: true };
}Use it as the source-of-truth schema for React/Vue adapters
// schemas/user.ts (shared by web + mobile)
import { createSchema } from "@valfuse-node/form";
export const userSchema = createSchema({/* … */});// web (React)
import { useValfuseForm as useReactValfuseForm } from "@valfuse-node/react";
const form = useReactValfuseForm({ schema: userSchema, defaultValues: { … } });<!-- mobile (Vue) -->
<script setup>
import { useValfuseForm as useVueValfuseForm } from "@valfuse-node/vue";
const form = useVueValfuseForm({ schema: userSchema, defaultValues: { … } });
</script>Release history
Release notes for this independently versioned package are maintained in the monorepo's changelog.
Support
For usage questions, bug reports, and feature requests, see the project support guide. For security reports, follow the security policy. Do not use public issues for security reports.
