@zerotal/validator 1.7.0 → 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.0",
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.0"
33
+ "@zerotal/core": "1.7.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.8.0"
@@ -113,6 +113,30 @@ export abstract class FormRequest {
113
113
  throw new Error(`${this.constructor.name}.rules() is not implemented.`);
114
114
  }
115
115
 
116
+ /**
117
+ * Normalise the raw body before the rules see it. Default: unchanged.
118
+ *
119
+ * This is where the shape of the *transport* gets reconciled with the shape of
120
+ * the rules. An HTML form has no way to say `null`: an unselected `<select>`
121
+ * posts `""`, so a `r.number().nullable()` field fails on the one input the
122
+ * browser can actually produce for "nothing". A client-side form can patch
123
+ * that up before it posts; a plain `<form method="post">` cannot, and a rule
124
+ * only reachable from a build with JavaScript is not a shared rule.
125
+ *
126
+ * Runs after `authorize()` and after the body is parsed, so it sees exactly
127
+ * what arrived. Return the object to validate — mutating and returning the
128
+ * argument is fine.
129
+ *
130
+ * @example
131
+ * override prepareForValidation(body: Record<string, unknown>) {
132
+ * if (body["assigneeId"] === "") body["assigneeId"] = null;
133
+ * return body;
134
+ * }
135
+ */
136
+ prepareForValidation(body: Record<string, unknown>): Record<string, unknown> {
137
+ return body;
138
+ }
139
+
116
140
  /**
117
141
  * Attach a helper method to all FormRequest instances at runtime.
118
142
  * Designed for framework packages (e.g. @zerotal/auth) that expose
@@ -193,6 +217,10 @@ export abstract class FormRequest {
193
217
  schema[key] = rule._def;
194
218
  }
195
219
 
220
+ // The transport's shape reconciled with the rules' shape, before the rules
221
+ // run. See `prepareForValidation` for why an HTML form needs this at all.
222
+ body = instance.prepareForValidation(body);
223
+
196
224
  const result = await runValidationAsync(schema as Schema, body);
197
225
  const session = (ctx as unknown as { session?: SessionLike }).session;
198
226
 
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,18 +130,46 @@ 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;
136
160
  }
137
161
 
138
- if (!isRequired && (value === undefined || value === null)) {
162
+ // Absent → skipped, which is what `optional()` means. An explicit `null` on
163
+ // a field that also declared `nullable()` is *not* absence: it is someone
164
+ // saying "clear this", and it falls through to the branch below so it
165
+ // reaches the output. Without this, `optional().nullable()` silently drops
166
+ // the null, `fill()` sees `undefined`, and "unassign" saves nothing while
167
+ // reporting success.
168
+ //
169
+ // `null` on a field that is optional but *not* nullable keeps the old
170
+ // behaviour — skipped rather than an error — so this widens what survives
171
+ // validation without newly rejecting anything.
172
+ if (!isRequired && (value === undefined || (value === null && !def.nullable))) {
139
173
  continue;
140
174
  }
141
175