@zerotal/validator 1.7.2 → 1.7.4
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/CHANGELOG.md +18 -0
- package/api-surface.md +1 -0
- package/package.json +2 -2
- package/src/Validator.ts +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.7.3] — 2026-08-20
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **An optional `number` or `date` reads `""` as absence.** An HTML `<select>` has one way to
|
|
16
|
+
say "none" — `<option value="">` — and it sends the empty string. `r.number().optional()`
|
|
17
|
+
refused it, so a form with an optional assignee could not be submitted _without_ one: the
|
|
18
|
+
single case the field was optional for. The message named a field the reader never touched,
|
|
19
|
+
which is the part that made it expensive to find.
|
|
20
|
+
|
|
21
|
+
It is now read as no value. The field is skipped, or set to `null` when the rule is also
|
|
22
|
+
`nullable()` — because clearing is what that option means, and skipping would leave the
|
|
23
|
+
previous value in place while reporting success. This mirrors how an explicit `null` on a
|
|
24
|
+
`nullable()` field already behaves.
|
|
25
|
+
|
|
26
|
+
Narrow on purpose: `string` still keeps `""`, where it may be a value someone meant, and the
|
|
27
|
+
remaining types wait for a case that needs them. Nothing that validated before stops doing so.
|
|
28
|
+
|
|
11
29
|
## [1.5.0] — 2026-08-15
|
|
12
30
|
|
|
13
31
|
### Fixed
|
package/api-surface.md
CHANGED
|
@@ -130,6 +130,7 @@ class FormRequest = {
|
|
|
130
130
|
static macro: (name: string, fn: (this: FormRequest, ...args: unknown[]) => unknown) => void
|
|
131
131
|
static validate: <T extends FormRequest>(this: new () => T) => Promise<Infer<ExtractDefs<ReturnType<T['rules']>>>>
|
|
132
132
|
authorize: () => boolean | Promise<boolean>
|
|
133
|
+
prepareForValidation: (body: Record<string, unknown>) => Record<string, unknown>
|
|
133
134
|
rules: (_r: RuleBuilder) => Record<string, FieldRule>
|
|
134
135
|
}
|
|
135
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/validator",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.7.
|
|
33
|
+
"@zerotal/core": "1.7.4"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.8.0"
|
package/src/Validator.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type { UniqueOptions } from "./dbRules.ts";
|
|
|
10
10
|
import { RULES } from "./stringRules.ts";
|
|
11
11
|
|
|
12
12
|
/** Resolve whether a field is effectively required given its conditional modifiers. */
|
|
13
|
+
/**
|
|
14
|
+
* Rule types for which the empty string cannot be a value, so an optional field
|
|
15
|
+
* handed `""` is a form saying "none" rather than data to check.
|
|
16
|
+
*/
|
|
17
|
+
const EMPTY_MEANS_ABSENT: ReadonlySet<FieldRuleDefinition["type"]> = new Set(["number", "date"]);
|
|
18
|
+
|
|
13
19
|
function resolveRequired(def: FieldRuleDefinition, input: Record<string, unknown>): boolean {
|
|
14
20
|
if (def.requiredIf !== undefined) {
|
|
15
21
|
const cond = def.requiredIf;
|
|
@@ -124,12 +130,30 @@ export function runValidation<S extends Schema>(
|
|
|
124
130
|
continue;
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
const isRequired = resolveRequired(def, input);
|
|
134
|
+
|
|
135
|
+
// An HTML form has exactly one way to say "no value": the empty string. A
|
|
136
|
+
// `<select>`'s placeholder option sends it, and for a type that can never
|
|
137
|
+
// hold `""` — a number, a date — reading it as the literal text rather than
|
|
138
|
+
// as absence is what made an optional select unsubmittable in the one case
|
|
139
|
+
// it was optional for. The message named a field the reader never touched,
|
|
140
|
+
// which is the part that made it expensive to find.
|
|
141
|
+
//
|
|
142
|
+
// Narrow deliberately. `string` keeps `""`, where it may be a value someone
|
|
143
|
+
// meant; the remaining types wait for a case that needs them. Each is a
|
|
144
|
+
// widening when it comes — nothing that validated before stops doing so.
|
|
145
|
+
if (!isRequired && value === "" && EMPTY_MEANS_ABSENT.has(def.type)) {
|
|
146
|
+
// `nullable()` is the field saying it can be cleared, and clearing is what
|
|
147
|
+
// the placeholder option is for — so it reaches the output as null rather
|
|
148
|
+
// than being skipped, exactly as an explicit null does below. Skipping it
|
|
149
|
+
// would leave the previous value in place and report success.
|
|
150
|
+
value = def.nullable ? null : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
if ((value === undefined || value === null) && def.defaultVal !== undefined) {
|
|
128
154
|
value = def.defaultVal;
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
const isRequired = resolveRequired(def, input);
|
|
132
|
-
|
|
133
157
|
if (isRequired && (value === undefined || value === null || value === "")) {
|
|
134
158
|
errors[field] ??= def.requiredMessage ?? `The ${field} field is required.`;
|
|
135
159
|
continue;
|