@valfuse-node/form 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +578 -0
- package/dist/index.d.mts +554 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.js +641 -0
- package/dist/index.mjs +583 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
# @valfuse-node/form
|
|
2
|
+
|
|
3
|
+
> Framework-agnostic form domain: schema definition, rule-based validation, value transformation, error normalization, and pure state-management primitives. Zero React/Vue dependencies — usable in Node.js, server actions, CLI tools, or any framework.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @valfuse-node/form
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [Why `@valfuse-node/form`?](#why-valfuse-nodeform)
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
15
|
+
- [Schema Definition](#schema-definition)
|
|
16
|
+
- [Built-in Rules](#built-in-rules)
|
|
17
|
+
- [Value Transformation](#value-transformation)
|
|
18
|
+
- [Validation](#validation)
|
|
19
|
+
- [Error Handling](#error-handling)
|
|
20
|
+
- [Framework-Agnostic State](#framework-agnostic-state)
|
|
21
|
+
- [Type Reference](#type-reference)
|
|
22
|
+
- [Development Usage](#development-usage)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Why `@valfuse-node/form`?
|
|
28
|
+
|
|
29
|
+
The form package is the **shared contract** for the entire `@valfuse-node` ecosystem:
|
|
30
|
+
|
|
31
|
+
- **Use directly in Node.js / server actions** — no React or Vue required.
|
|
32
|
+
- **Use as the validation engine** for `useReactValfuseForm` / `useVueValfuseForm` — the adapters read the same `ValfuseSchema` and `ValfuseFormState` interfaces.
|
|
33
|
+
- **Type-safe end-to-end** — `defaultValues` infers the value type; `setErrors` is typed by your field names; `formState.errors` is a `Record<keyof TFieldValues, ValfuseFieldError>`.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createSchema, validateSchema, transformValues, t } from "@valfuse-node/form";
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
createSchema,
|
|
46
|
+
validateSchema,
|
|
47
|
+
transformValues,
|
|
48
|
+
t,
|
|
49
|
+
} from "@valfuse-node/form";
|
|
50
|
+
|
|
51
|
+
const schema = createSchema({
|
|
52
|
+
email: {
|
|
53
|
+
type: "string",
|
|
54
|
+
transform: t.pipe(t.trim, t.toLowerCase),
|
|
55
|
+
rules: [
|
|
56
|
+
{ name: "required", error: { message: "Email is required", code: "email.required" } },
|
|
57
|
+
{ name: "email", error: { message: "Invalid email", code: "email.invalid" } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
age: {
|
|
61
|
+
type: "number",
|
|
62
|
+
transform: t.toInteger,
|
|
63
|
+
rules: [
|
|
64
|
+
{ name: "required", error: { message: "Required", code: "age.required" } },
|
|
65
|
+
{ name: "min", value: 18, error: { message: "Must be 18+", code: "age.min" } },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// 1. Coerce raw input (e.g. strings from <input>) to typed values
|
|
71
|
+
const typed = transformValues(schema, { email: " ALICE@EXAMPLE.COM ", age: "25" });
|
|
72
|
+
// → { email: "alice@example.com", age: 25 }
|
|
73
|
+
|
|
74
|
+
// 2. Validate the typed values
|
|
75
|
+
const errors = validateSchema(schema, typed);
|
|
76
|
+
// → {} (passes)
|
|
77
|
+
|
|
78
|
+
// 3. Inject server errors if any
|
|
79
|
+
// form.setErrors({ email: { message: "Already registered", code: "auth.duplicate" } });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Schema Definition
|
|
85
|
+
|
|
86
|
+
`createSchema(definition)` is an identity function whose only job is to give you **autocomplete and type inference** for the schema.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { createSchema } from "@valfuse-node/form";
|
|
90
|
+
|
|
91
|
+
const schema = createSchema({
|
|
92
|
+
// ─── string fields ──────────────────────────────────────────────────────────
|
|
93
|
+
name: {
|
|
94
|
+
type: "string",
|
|
95
|
+
rules: [
|
|
96
|
+
{ name: "required", error: { message: "Required" } },
|
|
97
|
+
{ name: "minLength", value: 2, error: { message: "Min 2 chars" } },
|
|
98
|
+
{ name: "maxLength", value: 50, error: { message: "Max 50 chars" } },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// ─── number fields ──────────────────────────────────────────────────────────
|
|
103
|
+
age: {
|
|
104
|
+
type: "number",
|
|
105
|
+
transform: t.toInteger, // optional pre-validation coercion
|
|
106
|
+
rules: [
|
|
107
|
+
{ name: "min", value: 0, error: { message: "Must be ≥ 0" } },
|
|
108
|
+
{ name: "max", value: 120, error: { message: "Must be ≤ 120" } },
|
|
109
|
+
{ name: "int", error: { message: "Whole numbers only" } },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// ─── boolean fields ─────────────────────────────────────────────────────────
|
|
114
|
+
agreed: {
|
|
115
|
+
type: "boolean",
|
|
116
|
+
rules: [
|
|
117
|
+
{ name: "accepted", error: { message: "You must accept the terms" } },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// ─── array fields ───────────────────────────────────────────────────────────
|
|
122
|
+
tags: {
|
|
123
|
+
type: "array",
|
|
124
|
+
rules: [
|
|
125
|
+
{ name: "minItems", value: 1, error: { message: "Add at least 1 tag" } },
|
|
126
|
+
{ name: "maxItems", value: 10, error: { message: "Max 10 tags" } },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ─── object fields (nested) ─────────────────────────────────────────────────
|
|
131
|
+
address: {
|
|
132
|
+
type: "object",
|
|
133
|
+
rules: [
|
|
134
|
+
{ name: "required", error: { message: "Address is required" } },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Supported field types
|
|
141
|
+
|
|
142
|
+
| Type | Description |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `"string"` | Free text, validated by string rules |
|
|
145
|
+
| `"number"` | Coerced numeric value (use `transform: t.toNumber` to coerce raw strings) |
|
|
146
|
+
| `"boolean"` | `true` / `false` |
|
|
147
|
+
| `"array"` | Any array (length-based rules only — element validation is a `custom` rule) |
|
|
148
|
+
| `"object"` | Any object (shape and presence rules only) |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Built-in Rules
|
|
153
|
+
|
|
154
|
+
### String rules
|
|
155
|
+
|
|
156
|
+
| Rule | Value | Example |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `required` | — | `{ name: "required", error: { message: "Required" } }` |
|
|
159
|
+
| `min` | `number` (length) | `{ name: "min", value: 3, error: … }` |
|
|
160
|
+
| `max` | `number` (length) | `{ name: "max", value: 100, error: … }` |
|
|
161
|
+
| `length` | `number` (exact length) | `{ name: "length", value: 10, error: … }` |
|
|
162
|
+
| `email` | — | `{ name: "email", error: … }` |
|
|
163
|
+
| `url` | — | `{ name: "url", error: … }` |
|
|
164
|
+
| `uuid` | — | `{ name: "uuid", error: … }` |
|
|
165
|
+
| `regex` | `RegExp` or `{ pattern, flags }` | `{ name: "regex", value: /^[a-z]+$/, error: … }` |
|
|
166
|
+
| `includes` | `string` | `{ name: "includes", value: "@", error: … }` |
|
|
167
|
+
| `startsWith` | `string` | `{ name: "startsWith", value: "https://", error: … }` |
|
|
168
|
+
| `endsWith` | `string` | `{ name: "endsWith", value: ".com", error: … }` |
|
|
169
|
+
|
|
170
|
+
### Number rules
|
|
171
|
+
|
|
172
|
+
| Rule | Value | Notes |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `required` | — | Rejects `null`, `undefined`, `NaN` |
|
|
175
|
+
| `min` | `number` | Inclusive lower bound |
|
|
176
|
+
| `max` | `number` | Inclusive upper bound |
|
|
177
|
+
| `gt` | `number` | Strictly greater than |
|
|
178
|
+
| `gte` | `number` | Greater than or equal |
|
|
179
|
+
| `lt` | `number` | Strictly less than |
|
|
180
|
+
| `lte` | `number` | Less than or equal |
|
|
181
|
+
| `int` | — | Rejects non-integers |
|
|
182
|
+
| `positive` | — | `> 0` |
|
|
183
|
+
| `nonnegative` | — | `≥ 0` |
|
|
184
|
+
| `negative` | — | `< 0` |
|
|
185
|
+
| `nonpositive` | — | `≤ 0` |
|
|
186
|
+
| `multipleOf` | `number` | `value % multipleOf === 0` |
|
|
187
|
+
|
|
188
|
+
### Boolean rules
|
|
189
|
+
|
|
190
|
+
| Rule | Value | Notes |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `required` | — | Rejects `null`, `undefined`, `false` |
|
|
193
|
+
| `literal` | `boolean` | Must match exactly |
|
|
194
|
+
| `accepted` | — | Sugar for `literal: true` (terms-of-service pattern) |
|
|
195
|
+
|
|
196
|
+
### Array rules
|
|
197
|
+
|
|
198
|
+
| Rule | Value |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `required` | — |
|
|
201
|
+
| `min` | `number` (min length) |
|
|
202
|
+
| `max` | `number` (max length) |
|
|
203
|
+
| `length` | `number` (exact length) |
|
|
204
|
+
| `nonempty` | — (length ≥ 1) |
|
|
205
|
+
|
|
206
|
+
### Object rules
|
|
207
|
+
|
|
208
|
+
| Rule | Value |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `required` | — (rejects `null` / `undefined`) |
|
|
211
|
+
| `shape` | `Record<string, unknown>` (key set must match) |
|
|
212
|
+
|
|
213
|
+
### Generic (all types)
|
|
214
|
+
|
|
215
|
+
| Rule | Shape | Use |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `custom` | `{ name: "custom", validate: (v, all) => boolean, error }` | Ad-hoc validator with access to all values |
|
|
218
|
+
| `refine` | Same as `custom` | Alias — same implementation, different intent name |
|
|
219
|
+
| `matchField` | `{ name: "matchField", value: "<other-field-name>", error }` | Cross-field equality (e.g. password confirmation) |
|
|
220
|
+
| `oneOf` | `{ name: "oneOf", value: unknown[], error }` | Value must be in the list |
|
|
221
|
+
| `notOneOf` | `{ name: "notOneOf", value: unknown[], error }` | Value must NOT be in the list |
|
|
222
|
+
|
|
223
|
+
**Example — cross-field password match:**
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const schema = createSchema({
|
|
227
|
+
password: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
|
|
228
|
+
confirmPassword: { type: "string", rules: [{ name: "matchField", value: "password", error: { message: "Passwords do not match" } }] },
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Example — custom rule with access to sibling values:**
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const schema = createSchema({
|
|
236
|
+
startDate: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
|
|
237
|
+
endDate: {
|
|
238
|
+
type: "string",
|
|
239
|
+
rules: [
|
|
240
|
+
{
|
|
241
|
+
name: "custom",
|
|
242
|
+
validate: (value, all) => new Date(value as string) > new Date(all.startDate as string),
|
|
243
|
+
error: { message: "End date must be after start date" },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Value Transformation
|
|
253
|
+
|
|
254
|
+
`transform` runs **before validation and before submission**. It is a single function or a `t.pipe(...)` composition.
|
|
255
|
+
|
|
256
|
+
### Built-in transformers (`t`)
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { t } from "@valfuse-node/form";
|
|
260
|
+
|
|
261
|
+
// ─── String transformers ──────────────────────────────────────────────────────
|
|
262
|
+
t.trim // " hi " → "hi"
|
|
263
|
+
t.trimStart // " hi " → "hi "
|
|
264
|
+
t.trimEnd // " hi " → " hi"
|
|
265
|
+
t.toLowerCase // "Hi" → "hi"
|
|
266
|
+
t.toUpperCase // "hi" → "HI"
|
|
267
|
+
t.toTitleCase // "hello world" → "Hello World"
|
|
268
|
+
t.toSentenceCase // "HELLO" → "Hello"
|
|
269
|
+
t.collapseSpaces // "a b" → "a b"
|
|
270
|
+
|
|
271
|
+
// ─── Coercion transformers ───────────────────────────────────────────────────
|
|
272
|
+
t.toNumber // "42" → 42 (returns original if NaN)
|
|
273
|
+
t.toInteger // "42.7" → 42
|
|
274
|
+
t.toFloat // "3.14" → 3.14
|
|
275
|
+
t.toBoolean // "true"/"1"/1/true → true; everything else → false
|
|
276
|
+
|
|
277
|
+
// ─── Composition ──────────────────────────────────────────────────────────────
|
|
278
|
+
t.pipe(t.trim, t.toLowerCase) // compose left-to-right
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Custom transformers
|
|
282
|
+
|
|
283
|
+
Any function `(value: unknown) => unknown` is a valid transformer:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const slugify = (v: unknown) =>
|
|
287
|
+
typeof v === "string" ? v.toLowerCase().replace(/\s+/g, "-") : v;
|
|
288
|
+
|
|
289
|
+
const schema = createSchema({
|
|
290
|
+
slug: {
|
|
291
|
+
type: "string",
|
|
292
|
+
transform: slugify,
|
|
293
|
+
rules: [{ name: "required", error: { message: "Required" } }],
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### `transformValues(schema, values)`
|
|
299
|
+
|
|
300
|
+
Apply all per-field transforms in one call — the canonical pre-submit pipeline:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { transformValues } from "@valfuse-node/form";
|
|
304
|
+
|
|
305
|
+
const typed = transformValues(schema, { email: " Alice@Example.com ", age: "25" });
|
|
306
|
+
// → { email: "alice@example.com", age: 25 }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Fields without a `transform` are passed through unchanged. The original `values` object is **never mutated**.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Validation
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { validateSchema } from "@valfuse-node/form";
|
|
317
|
+
|
|
318
|
+
const errors = validateSchema(loginSchema, {
|
|
319
|
+
email: "bad",
|
|
320
|
+
password: "123",
|
|
321
|
+
});
|
|
322
|
+
// → {
|
|
323
|
+
// email: { message: "Invalid email format", type: "validation", code: "email.invalid" },
|
|
324
|
+
// password: { message: "Min 8 chars", type: "validation", code: "password.min" },
|
|
325
|
+
// }
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Returns:** `Record<string, ValfuseError>`. Empty object `{}` means valid.
|
|
329
|
+
|
|
330
|
+
**Error shape:**
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
interface ValfuseError {
|
|
334
|
+
message: string; // user-facing message
|
|
335
|
+
type?: "validation" | "server" | "manual" | "custom";
|
|
336
|
+
code?: string; // semantic code (e.g. "email.required")
|
|
337
|
+
metadata?: Record<string, unknown>; // extra context
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
> **Note:** `validateSchema` returns 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.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Error Handling
|
|
346
|
+
|
|
347
|
+
### `normalizeError(error)`
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { normalizeError } from "@valfuse-node/form";
|
|
351
|
+
|
|
352
|
+
normalizeError("Something went wrong");
|
|
353
|
+
// → { message: "Something went wrong" }
|
|
354
|
+
|
|
355
|
+
normalizeError({ message: "Boom", code: "boom.explode" });
|
|
356
|
+
// → { message: "Boom", code: "boom.explode" }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Useful when you need to merge API errors (which may be strings or objects) into the same shape your form expects.
|
|
360
|
+
|
|
361
|
+
### `ValfuseFieldError` (the shape used by adapters)
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
interface ValfuseFieldError {
|
|
365
|
+
message: string;
|
|
366
|
+
type?: string; // "validation" | "server" | "manual" | "custom"
|
|
367
|
+
code?: string; // e.g. "email.required", "auth.not_found"
|
|
368
|
+
metadata?: Record<string, unknown>;
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Error types by origin
|
|
373
|
+
|
|
374
|
+
| `type` | Origin | Typical use |
|
|
375
|
+
|---|---|---|
|
|
376
|
+
| `"validation"` | A schema rule failed | Automatic — emitted by `validateSchema` |
|
|
377
|
+
| `"server"` | Injected via `form.setErrors` after a failed API call | Manual |
|
|
378
|
+
| `"manual"` | Injected via `form.setErrors` for client-only logic | Manual |
|
|
379
|
+
| `"custom"` | Returned by a `custom` / `refine` rule | Automatic — but tagged "custom" so consumers can distinguish |
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Framework-Agnostic State
|
|
384
|
+
|
|
385
|
+
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).
|
|
386
|
+
|
|
387
|
+
### Values
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { createValuesState, updateValue, resetValues, computeIsDirty, computeDirtyFields } from "@valfuse-node/form";
|
|
391
|
+
|
|
392
|
+
const state = createValuesState({ email: "", age: 0 });
|
|
393
|
+
updateValue(state, "email", "alice@example.com");
|
|
394
|
+
const isDirty = computeIsDirty(state, { email: "", age: 0 });
|
|
395
|
+
// → true
|
|
396
|
+
const dirty = computeDirtyFields(state, { email: "", age: 0 });
|
|
397
|
+
// → { email: true }
|
|
398
|
+
resetValues(state, { email: "", age: 0 });
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Touched
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { createTouchedState, markTouched, isTouched, toTouchedFieldsRecord } from "@valfuse-node/form";
|
|
405
|
+
|
|
406
|
+
const touched = createTouchedState();
|
|
407
|
+
markTouched(touched, "email");
|
|
408
|
+
isTouched(touched, "email"); // true
|
|
409
|
+
toTouchedFieldsRecord(touched); // { email: true }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Errors
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import { createErrorsState, setFieldError, clearFieldErrors, hasErrors, getFieldError, toFormErrors } from "@valfuse-node/form";
|
|
416
|
+
|
|
417
|
+
const errors = createErrorsState();
|
|
418
|
+
setFieldError(errors, "email", { message: "Taken", code: "auth.duplicate" });
|
|
419
|
+
hasErrors(errors); // true
|
|
420
|
+
getFieldError(errors, "email"); // { message: "Taken", code: "auth.duplicate" }
|
|
421
|
+
clearFieldErrors(errors);
|
|
422
|
+
toFormErrors(errors); // {} (object form)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Submission
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
import { createSubmissionState, startSubmit, endSubmitSuccess, endSubmitFailure, resetSubmission } from "@valfuse-node/form";
|
|
429
|
+
|
|
430
|
+
const sub = createSubmissionState();
|
|
431
|
+
startSubmit(sub);
|
|
432
|
+
try {
|
|
433
|
+
await api.call();
|
|
434
|
+
endSubmitSuccess(sub);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
endSubmitFailure(sub);
|
|
437
|
+
} finally {
|
|
438
|
+
// sub.isSubmitting === false
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## Type Reference
|
|
445
|
+
|
|
446
|
+
### `ValfuseSchema`
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
type ValfuseSchema = Record<string, ValfuseFieldSchema>;
|
|
450
|
+
|
|
451
|
+
type ValfuseFieldSchema =
|
|
452
|
+
| ValfuseStringFieldSchema
|
|
453
|
+
| ValfuseNumberFieldSchema
|
|
454
|
+
| ValfuseBooleanFieldSchema
|
|
455
|
+
| ValfuseArrayFieldSchema
|
|
456
|
+
| ValfuseObjectFieldSchema;
|
|
457
|
+
|
|
458
|
+
interface ValfuseStringFieldSchema {
|
|
459
|
+
type: "string";
|
|
460
|
+
rules: (ValfuseStringRule | ValfuseGenericRule)[];
|
|
461
|
+
transform?: ValfuseTransformer;
|
|
462
|
+
}
|
|
463
|
+
// (same shape for the other four types)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Rule types
|
|
467
|
+
|
|
468
|
+
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.
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
// Generic (work on any field type)
|
|
472
|
+
type ValfuseGenericRule =
|
|
473
|
+
| { name: "custom"; validate: (v, all) => boolean; error: ValfuseRuleError }
|
|
474
|
+
| { name: "refine"; validate: (v, all) => boolean; error: ValfuseRuleError }
|
|
475
|
+
| { name: "matchField"; value: string; error: ValfuseRuleError }
|
|
476
|
+
| { name: "oneOf"; value: unknown[]; error: ValfuseRuleError }
|
|
477
|
+
| { name: "notOneOf"; value: unknown[]; error: ValfuseRuleError };
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Error types
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
type ValfuseErrorType = "validation" | "server" | "manual" | "custom";
|
|
484
|
+
|
|
485
|
+
interface ValfuseError {
|
|
486
|
+
message: string;
|
|
487
|
+
type?: ValfuseErrorType | string;
|
|
488
|
+
code?: string;
|
|
489
|
+
metadata?: Record<string, unknown>;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type ValfuseFieldErrors<TFieldName extends string = string> = Partial<
|
|
493
|
+
Record<TFieldName, string | ValfuseError>
|
|
494
|
+
>;
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Development Usage
|
|
500
|
+
|
|
501
|
+
### Use it in a Node.js script
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
// scripts/validate-signup.ts
|
|
505
|
+
import { createSchema, validateSchema, transformValues } from "@valfuse-node/form";
|
|
506
|
+
|
|
507
|
+
const schema = createSchema({
|
|
508
|
+
email: { type: "string", transform: (v) => String(v).toLowerCase(), rules: [{ name: "required", error: { message: "Required" } }] },
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const input = process.argv[2] ?? "";
|
|
512
|
+
const errors = validateSchema(schema, transformValues(schema, { email: input }));
|
|
513
|
+
|
|
514
|
+
if (Object.keys(errors).length) {
|
|
515
|
+
console.error("Invalid:", errors);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
console.log("OK");
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
npx tsx scripts/validate-signup.ts "alice@example.com"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Use it in a server action
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
// app/actions/signup.ts
|
|
529
|
+
"use server";
|
|
530
|
+
import { createSchema, validateSchema, transformValues, normalizeError } from "@valfuse-node/form";
|
|
531
|
+
|
|
532
|
+
const schema = createSchema({
|
|
533
|
+
email: { type: "string", rules: [{ name: "required", error: { message: "Email required" } }, { name: "email", error: { message: "Invalid" } }] },
|
|
534
|
+
password: { type: "string", rules: [{ name: "required", error: { message: "Password required" } }, { name: "minLength", value: 8, error: { message: "Min 8" } }] },
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
export async function signupAction(formData: FormData) {
|
|
538
|
+
const typed = transformValues(schema, {
|
|
539
|
+
email: String(formData.get("email") ?? ""),
|
|
540
|
+
password: String(formData.get("password") ?? ""),
|
|
541
|
+
});
|
|
542
|
+
const errors = validateSchema(schema, typed);
|
|
543
|
+
|
|
544
|
+
if (Object.keys(errors).length) {
|
|
545
|
+
return { ok: false, errors };
|
|
546
|
+
}
|
|
547
|
+
// … DB insert
|
|
548
|
+
return { ok: true };
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Use it as the source-of-truth schema for React/Vue adapters
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
// schemas/user.ts (shared by web + mobile)
|
|
556
|
+
import { createSchema } from "@valfuse-node/form";
|
|
557
|
+
export const userSchema = createSchema({ /* … */ });
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
```tsx
|
|
561
|
+
// web (React)
|
|
562
|
+
import { useReactValfuseForm } from "@valfuse-node/core";
|
|
563
|
+
const form = useReactValfuseForm({ schema: userSchema, defaultValues: { … } });
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
```vue
|
|
567
|
+
<!-- mobile (Vue) -->
|
|
568
|
+
<script setup>
|
|
569
|
+
import { useVueValfuseForm } from "@valfuse-node/core";
|
|
570
|
+
const form = useVueValfuseForm({ schema: userSchema, defaultValues: { … } });
|
|
571
|
+
</script>
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## License
|
|
577
|
+
|
|
578
|
+
[MIT](../../LICENSE)
|