@zerotal/validator 1.4.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **`Infer<>` resolves `array` and `object` to their contents.** Both inferred
16
+ `unknown`, which is worse than it sounds: the idiomatic `data.ids ?? []` turns
17
+ `unknown` into `{}`, so the error the user sees is
18
+ `Property 'map' does not exist on type '{}'` — pointing at the array they just
19
+ validated, with nothing to suggest the validator is where the type was lost.
20
+ `r.array(r.number())` is now `number[]`, `r.object({ … })` is the shape it was
21
+ given, and both nest to any depth. `nullable()` widens at every level rather
22
+ than only the top one.
23
+
24
+ `r.object()`'s shape parameter is inferred directly instead of through a
25
+ `Schema` constraint, which is what makes the field types reachable at all.
26
+
11
27
  ## [1.0.3] — 2026-08-07
12
28
 
13
29
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/validator",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@zerotal/core": "1.4.0"
32
+ "@zerotal/core": "1.5.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.0"
package/src/FieldRule.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FieldRuleDefinition, Schema, CustomFn } from "./types.ts";
1
+ import type { FieldRuleDefinition, CustomFn } from "./types.ts";
2
2
  import type { UniqueOptions } from "./dbRules.ts";
3
3
 
4
4
  // Narrow definition types — each class's _def has a literal 'type' field so
@@ -6,8 +6,17 @@ import type { UniqueOptions } from "./dbRules.ts";
6
6
  type StringDef = Omit<FieldRuleDefinition, "type"> & { type: "string" };
7
7
  type NumberDef = Omit<FieldRuleDefinition, "type"> & { type: "number" };
8
8
  type BooleanDef = Omit<FieldRuleDefinition, "type"> & { type: "boolean" };
9
- type ArrayDef = Omit<FieldRuleDefinition, "type"> & { type: "array" };
10
- type ObjectDef = Omit<FieldRuleDefinition, "type"> & { type: "object" };
9
+ // array/object are the two whose inferred type depends on more than `type`, so their
10
+ // defs keep the item/shape definitions in the type rather than widening them to the
11
+ // optional `children`/`shape` on FieldRuleDefinition. Without this the item type is
12
+ // erased and `Infer<>` can only say `unknown` for the whole array.
13
+ type ArrayDef<C extends FieldRuleDefinition = FieldRuleDefinition> = Omit<
14
+ FieldRuleDefinition,
15
+ "type" | "children"
16
+ > & { type: "array"; children: C };
17
+ type ObjectDef<
18
+ Sh extends Record<string, FieldRuleDefinition> = Record<string, FieldRuleDefinition>,
19
+ > = Omit<FieldRuleDefinition, "type" | "shape"> & { type: "object"; shape: Sh };
11
20
 
12
21
  type RuleEntry = { name: string; args: unknown[]; message?: string; fn?: CustomFn };
13
22
 
@@ -513,7 +522,7 @@ export class BooleanRule extends FieldRule {
513
522
  }
514
523
 
515
524
  export class ArrayRule<T extends FieldRule> extends FieldRule {
516
- readonly _def: ArrayDef;
525
+ readonly _def: ArrayDef<T["_def"]>;
517
526
 
518
527
  constructor(item: T) {
519
528
  super();
@@ -544,10 +553,13 @@ export class ArrayRule<T extends FieldRule> extends FieldRule {
544
553
  }
545
554
  }
546
555
 
547
- export class ObjectRule<S extends Schema> extends FieldRule {
548
- readonly _def: ObjectDef;
556
+ // Generic over the *rules* passed in, not over the definitions they produce. The old
557
+ // `S extends Schema` had no inference site — `{ [K in keyof S]: FieldRule }` mentions
558
+ // S only in its keys — so S collapsed to its constraint and the shape was lost.
559
+ export class ObjectRule<S extends Record<string, FieldRule>> extends FieldRule {
560
+ readonly _def: ObjectDef<{ [K in keyof S]: S[K]["_def"] }>;
549
561
 
550
- constructor(shape: { [K in keyof S]: FieldRule }) {
562
+ constructor(shape: S) {
551
563
  super();
552
564
  const shapeRecord: Record<string, FieldRuleDefinition> = {};
553
565
  for (const [k, v] of Object.entries(shape)) {
@@ -559,7 +571,7 @@ export class ObjectRule<S extends Schema> extends FieldRule {
559
571
  nullable: false,
560
572
  defaultVal: undefined,
561
573
  rules: [],
562
- shape: shapeRecord,
574
+ shape: shapeRecord as { [K in keyof S]: S[K]["_def"] },
563
575
  };
564
576
  }
565
577
  }
@@ -62,6 +62,8 @@ type SessionLike = {
62
62
  * const data = await StorePostRequest.validate();
63
63
  * // data.title → string (fully typed — no casts needed)
64
64
  */
65
+ // Merged with the empty interface below — the app-augmentable seam for typed fields.
66
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
65
67
  export abstract class FormRequest {
66
68
  /**
67
69
  * Access the current request's HttpContext from within instance methods.
@@ -251,5 +253,5 @@ export abstract class FormRequest {
251
253
  * }
252
254
  * }
253
255
  */
254
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
256
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
255
257
  export interface FormRequest {}
@@ -9,7 +9,6 @@ import {
9
9
  PasswordRule,
10
10
  } from "./FieldRule.ts";
11
11
  import type { FieldRule } from "./FieldRule.ts";
12
- import type { Schema } from "./types.ts";
13
12
 
14
13
  export class RuleBuilder {
15
14
  /**
@@ -59,7 +58,7 @@ export class RuleBuilder {
59
58
  return new ArrayRule(item);
60
59
  }
61
60
 
62
- object<S extends Schema>(shape: { [K in keyof S]: FieldRule }): ObjectRule<S> {
61
+ object<S extends Record<string, FieldRule>>(shape: S): ObjectRule<S> {
63
62
  return new ObjectRule<S>(shape);
64
63
  }
65
64
  }
package/src/types.ts CHANGED
@@ -47,7 +47,38 @@ export type InferFieldType<F extends FieldRuleDefinition> = F["type"] extends "s
47
47
  ? boolean
48
48
  : F["type"] extends "date"
49
49
  ? Date
50
- : unknown;
50
+ : F["type"] extends "array"
51
+ ? InferArrayType<F>
52
+ : F["type"] extends "object"
53
+ ? InferObjectType<F>
54
+ : unknown;
55
+
56
+ /**
57
+ * `array` and `object` carry their contents on `children` / `shape`, so unlike the
58
+ * scalar rules their inferred type is not decided by `type` alone. `ArrayRule` and
59
+ * `ObjectRule` thread the item/shape definitions through those two fields (see
60
+ * `ArrayDef` / `ObjectDef`), which is what these two read back out.
61
+ *
62
+ * Both degrade rather than fail: a definition widened to bare `FieldRuleDefinition`
63
+ * has an optional `children`/`shape` that infers nothing, and falls back to the
64
+ * loosest honest type instead of `never`.
65
+ */
66
+ type InferArrayType<F extends FieldRuleDefinition> = F extends {
67
+ children: infer C extends FieldRuleDefinition;
68
+ }
69
+ ? InferField<C>[]
70
+ : unknown[];
71
+
72
+ type InferObjectType<F extends FieldRuleDefinition> = F extends {
73
+ shape: infer Sh extends Record<string, FieldRuleDefinition>;
74
+ }
75
+ ? { [K in keyof Sh]: InferField<Sh[K]> }
76
+ : Record<string, unknown>;
77
+
78
+ /** One field's type, including its `nullable` widening. Shared by `Infer` and the nested inferers. */
79
+ type InferField<F extends FieldRuleDefinition> = F["nullable"] extends true
80
+ ? InferFieldType<F> | null
81
+ : InferFieldType<F>;
51
82
 
52
83
  /**
53
84
  * Infer the validated TypeScript type from a Schema.
@@ -55,9 +86,7 @@ export type InferFieldType<F extends FieldRuleDefinition> = F["type"] extends "s
55
86
  * distinction is enforced at runtime only. Trade-off acknowledged in the plan.
56
87
  */
57
88
  export type Infer<S extends Schema> = {
58
- [K in keyof S]: S[K]["nullable"] extends true
59
- ? InferFieldType<S[K]> | null
60
- : InferFieldType<S[K]>;
89
+ [K in keyof S]: InferField<S[K]>;
61
90
  };
62
91
 
63
92
  export type ValidationErrors = Record<string, string>;