@zerotal/validator 1.7.2 → 1.7.3

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 CHANGED
@@ -8,6 +8,22 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Fixed
12
+
13
+ - **An optional `number` or `date` reads `""` as absence.** An HTML `<select>` has one way to
14
+ say "none" — `<option value="">` — and it sends the empty string. `r.number().optional()`
15
+ refused it, so a form with an optional assignee could not be submitted _without_ one: the
16
+ single case the field was optional for. The message named a field the reader never touched,
17
+ which is the part that made it expensive to find.
18
+
19
+ It is now read as no value. The field is skipped, or set to `null` when the rule is also
20
+ `nullable()` — because clearing is what that option means, and skipping would leave the
21
+ previous value in place while reporting success. This mirrors how an explicit `null` on a
22
+ `nullable()` field already behaves.
23
+
24
+ Narrow on purpose: `string` still keeps `""`, where it may be a value someone meant, and the
25
+ remaining types wait for a case that needs them. Nothing that validated before stops doing so.
26
+
11
27
  ## [1.5.0] — 2026-08-15
12
28
 
13
29
  ### 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.2",
3
+ "version": "1.7.3",
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.2"
33
+ "@zerotal/core": "1.7.3"
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;