From 04c3125f51485e27ccc406d151f01ee51981db9a Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 16:55:50 -0600 Subject: [PATCH 1/8] Add required() and asRequired() methods to validators --- src/values/validator.test.ts | 324 +++++++++++++++++++++++++++++++++++ src/values/validators.ts | 132 ++++++++++++++ 2 files changed, 456 insertions(+) diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index e6aac4a8..f05d6f11 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -468,6 +468,257 @@ describe("v.object utility methods", () => { }); }); + describe("required", () => { + test("makes all fields required", () => { + const original = v.object({ + a: v.optional(v.string()), + b: v.optional(v.number()), + c: v.optional(v.boolean()), + }); + + const required = original.required(); + + // Type checks + assert< + Equals< + Infer, + { + a: string; + b: number; + c: boolean; + } + > + >(); + + // Runtime checks + expect(required.fields.a.isOptional).toBe("required"); + expect(required.fields.b.isOptional).toBe("required"); + expect(required.fields.c.isOptional).toBe("required"); + expect(required.isOptional).toBe("required"); + }); + + test("works with already required fields", () => { + const original = v.object({ + a: v.string(), + b: v.optional(v.number()), + c: v.boolean(), + }); + + const required = original.required(); + + // Type checks - all fields should be required + type Result = Infer; + const _test1: Result = { a: "hello", b: 42, c: true }; + // @ts-expect-error - fields should not be optional + const _test2: Result = { a: "hello" }; + + // Runtime checks + expect(required.fields.a.isOptional).toBe("required"); + expect(required.fields.b.isOptional).toBe("required"); + expect(required.fields.c.isOptional).toBe("required"); + }); + + test("makes VObject itself required", () => { + const original = v.object({ + a: v.optional(v.string()), + b: v.optional(v.number()), + }); + const optional = original.asOptional(); + const required = optional.required(); + + // Type checks + type Result = Infer; + const _test: Result = { a: "hello", b: 42 }; + + // Runtime check: Both VObject and fields become required + expect(required.isOptional).toBe("required"); + expect(required.fields.a.isOptional).toBe("required"); + expect(required.fields.b.isOptional).toBe("required"); + }); + + test("works with different validator types", () => { + const original = v.object({ + str: v.optional(v.string()), + num: v.optional(v.number()), + bool: v.optional(v.boolean()), + id: v.optional(v.id("users")), + arr: v.optional(v.array(v.string())), + literal: v.optional(v.literal("test")), + union: v.optional(v.union(v.string(), v.number())), + bytes: v.optional(v.bytes()), + int: v.optional(v.int64()), + nil: v.optional(v.null()), + any: v.optional(v.any()), + }); + + const required = original.required(); + + // Runtime checks - all should be required + expect(required.fields.str.isOptional).toBe("required"); + expect(required.fields.num.isOptional).toBe("required"); + expect(required.fields.bool.isOptional).toBe("required"); + expect(required.fields.id.isOptional).toBe("required"); + expect(required.fields.arr.isOptional).toBe("required"); + expect(required.fields.literal.isOptional).toBe("required"); + expect(required.fields.union.isOptional).toBe("required"); + expect(required.fields.bytes.isOptional).toBe("required"); + expect(required.fields.int.isOptional).toBe("required"); + expect(required.fields.nil.isOptional).toBe("required"); + expect(required.fields.any.isOptional).toBe("required"); + + // Verify validator kinds are preserved + expect(required.fields.str.kind).toBe("string"); + expect(required.fields.num.kind).toBe("float64"); + expect(required.fields.bool.kind).toBe("boolean"); + expect(required.fields.id.kind).toBe("id"); + expect(required.fields.arr.kind).toBe("array"); + expect(required.fields.literal.kind).toBe("literal"); + expect(required.fields.union.kind).toBe("union"); + expect(required.fields.bytes.kind).toBe("bytes"); + expect(required.fields.int.kind).toBe("int64"); + expect(required.fields.nil.kind).toBe("null"); + expect(required.fields.any.kind).toBe("any"); + }); + + test("works with nested objects", () => { + const original = v.object({ + nested: v.optional(v.object({ + inner: v.optional(v.string()), + required: v.number(), + })), + simple: v.optional(v.number()), + }); + + const required = original.required(); + + // Type checks + type Result = Infer; + const _test: Result = { + nested: { inner: "hello", required: 42 }, + simple: 42, + }; + + // Runtime checks + expect(required.fields.nested.isOptional).toBe("required"); + expect(required.fields.simple.isOptional).toBe("required"); + + // The nested object fields themselves are not changed by the parent's required() + const nestedObj = required.fields.nested as any; + expect(nestedObj.fields.inner.isOptional).toBe("optional"); + expect(nestedObj.fields.required.isOptional).toBe("required"); + }); + + test("works with complex nested structures", () => { + const original = v.object({ + user: v.optional(v.object({ + profile: v.optional(v.object({ + name: v.optional(v.string()), + age: v.number(), + })), + settings: v.object({ + theme: v.optional(v.literal("dark")), + notifications: v.boolean(), + }), + })), + metadata: v.optional(v.record(v.string(), v.any())), + tags: v.optional(v.array(v.string())), + }); + + const required = original.required(); + + // Runtime checks - only top-level fields should be made required + expect(required.fields.user.isOptional).toBe("required"); + expect(required.fields.metadata.isOptional).toBe("required"); + expect(required.fields.tags.isOptional).toBe("required"); + + // Verify nested structure is preserved + const userObj = required.fields.user as any; + expect(userObj.fields.profile.isOptional).toBe("optional"); + expect(userObj.fields.settings.isOptional).toBe("required"); + + const profileObj = userObj.fields.profile; + expect(profileObj.fields.name.isOptional).toBe("optional"); + expect(profileObj.fields.age.isOptional).toBe("required"); + }); + + test("empty object", () => { + const original = v.object({}); + const required = original.required(); + + // Type checks + assert, {}>>(); + + // Runtime checks + expect(required.fields).toEqual({}); + expect(required.isOptional).toBe("required"); + }); + + test("preserves validator properties", () => { + const original = v.object({ + id: v.optional(v.id("users")), + literal: v.optional(v.literal("test")), + array: v.optional(v.array(v.string())), + record: v.optional(v.record(v.string(), v.number())), + union: v.optional(v.union(v.string(), v.number())), + }); + + const required = original.required(); + + // Check that specific validator properties are preserved + expect((required.fields.id as any).tableName).toBe("users"); + expect((required.fields.literal as any).value).toBe("test"); + expect((required.fields.array as any).element.kind).toBe("string"); + expect((required.fields.record as any).key.kind).toBe("string"); + expect((required.fields.record as any).value.kind).toBe("float64"); + expect((required.fields.union as any).members).toHaveLength(2); + }); + }); + + describe("asOptional vs partial", () => { + test("asOptional only affects object, partial affects fields", () => { + const original = v.object({ + a: v.string(), + b: v.optional(v.number()), + }); + + const asOptional = original.asOptional(); + const partial = original.partial(); + + // asOptional: only object becomes optional, fields unchanged + expect(asOptional.isOptional).toBe("optional"); + expect(asOptional.fields.a.isOptional).toBe("required"); + expect(asOptional.fields.b.isOptional).toBe("optional"); + + // partial: object unchanged, all fields become optional + expect(partial.isOptional).toBe("required"); + expect(partial.fields.a.isOptional).toBe("optional"); + expect(partial.fields.b.isOptional).toBe("optional"); + }); + }); + + describe("asRequired vs required", () => { + test("asRequired only affects object, required affects both", () => { + const original = v.object({ + a: v.string(), + b: v.optional(v.number()), + }); + const optional = original.asOptional(); + + const asRequired = optional.asRequired(); + const required = optional.required(); + + // asRequired: only object becomes required, fields unchanged + expect(asRequired.isOptional).toBe("required"); + expect(asRequired.fields.a.isOptional).toBe("required"); + expect(asRequired.fields.b.isOptional).toBe("optional"); + + // required: both object and fields become required + expect(required.isOptional).toBe("required"); + expect(required.fields.a.isOptional).toBe("required"); + expect(required.fields.b.isOptional).toBe("required"); + }); + }); + describe("chaining utility methods", () => { test("can chain multiple operations", () => { const base = v.object({ @@ -499,6 +750,79 @@ describe("v.object utility methods", () => { expect(result.fields.a.isOptional).toBe("optional"); }); + test("can chain operations including required()", () => { + const base = v.object({ + a: v.optional(v.string()), + b: v.optional(v.number()), + c: v.optional(v.boolean()), + d: v.optional(v.int64()), + }); + + const result = base.required().omit("d").extend({ e: v.optional(v.bytes()) }); + + // Type checks + type Result = Infer; + const _test1: Result = { + a: "hello", + b: 42, + c: true, + e: new ArrayBuffer(0), + }; + const _test2: Result = { + a: "hello", + b: 42, + c: true, + // e is optional + }; + + // Runtime checks + expect(result.fields).toHaveProperty("a"); + expect(result.fields).toHaveProperty("b"); + expect(result.fields).toHaveProperty("c"); + expect(result.fields).toHaveProperty("e"); + expect(result.fields).not.toHaveProperty("d"); + + // Original fields became required, new field is optional + expect(result.fields.a.isOptional).toBe("required"); + expect(result.fields.b.isOptional).toBe("required"); + expect(result.fields.c.isOptional).toBe("required"); + expect(result.fields.e.isOptional).toBe("optional"); + }); + + test("required() in complex chain", () => { + const base = v.object({ + keep: v.string(), + remove: v.number(), + makeOptional: v.boolean(), + }); + + // partial -> pick -> extend -> required + const result = base + .partial() + .pick("keep", "makeOptional") + .extend({ + newRequired: v.string(), + newOptional: v.optional(v.number()), + }) + .required(); + + // Type checks + type Result = Infer; + const _test: Result = { + keep: "hello", + makeOptional: true, + newRequired: "world", + newOptional: 42, + }; + + // Runtime checks + expect(result.fields.keep.isOptional).toBe("required"); + expect(result.fields.makeOptional.isOptional).toBe("required"); + expect(result.fields.newRequired.isOptional).toBe("required"); + expect(result.fields.newOptional.isOptional).toBe("required"); + expect(result.fields).not.toHaveProperty("remove"); + }); + test("complex chaining scenario", () => { const user = v.object({ name: v.string(), diff --git a/src/values/validators.ts b/src/values/validators.ts index 54f6ef12..a849b23e 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -44,6 +44,8 @@ abstract class BaseValidator< abstract get json(): ValidatorJSON; /** @internal */ abstract asOptional(): Validator; + /** @internal */ + abstract asRequired(): Validator, "required", FieldPaths>; } /** @@ -90,6 +92,13 @@ export class VId< tableName: this.tableName, }); } + /** @internal */ + asRequired() { + return new VId, "required">({ + isOptional: "required", + tableName: this.tableName, + }); + } } /** @@ -115,6 +124,12 @@ export class VFloat64< isOptional: "optional", }); } + /** @internal */ + asRequired() { + return new VFloat64, "required">({ + isOptional: "required", + }); + } } /** @@ -138,6 +153,10 @@ export class VInt64< asOptional() { return new VInt64({ isOptional: "optional" }); } + /** @internal */ + asRequired() { + return new VInt64, "required">({ isOptional: "required" }); + } } /** @@ -162,6 +181,12 @@ export class VBoolean< isOptional: "optional", }); } + /** @internal */ + asRequired() { + return new VBoolean, "required">({ + isOptional: "required", + }); + } } /** @@ -184,6 +209,10 @@ export class VBytes< asOptional() { return new VBytes({ isOptional: "optional" }); } + /** @internal */ + asRequired() { + return new VBytes, "required">({ isOptional: "required" }); + } } /** @@ -208,6 +237,12 @@ export class VString< isOptional: "optional", }); } + /** @internal */ + asRequired() { + return new VString, "required">({ + isOptional: "required", + }); + } } /** @@ -230,6 +265,10 @@ export class VNull< asOptional() { return new VNull({ isOptional: "optional" }); } + /** @internal */ + asRequired() { + return new VNull, "required">({ isOptional: "required" }); + } } /** @@ -257,6 +296,12 @@ export class VAny< isOptional: "optional", }); } + /** @internal */ + asRequired() { + return new VAny, "required", FieldPaths>({ + isOptional: "required", + }); + } } /** @@ -317,12 +362,21 @@ export class VObject< }; } /** @internal */ + /** Only marks the object as optional. Fields are left unchanged. If you want the fields to be optional use .partial() */ asOptional() { return new VObject({ isOptional: "optional", fields: this.fields, }); } + /** @internal */ + /** Only marks the object as required. Fields are left unchanged. If you want the fields and object to be required use .required() */ + asRequired() { + return new VObject, Fields, "required", FieldPaths>({ + isOptional: "required", + fields: this.fields, + }); + } /** * Create a new VObject with the specified fields omitted. @@ -382,6 +436,26 @@ export class VObject< }); } + /** + * Create a new VObject with all fields marked as required & the object marked as required. + */ + required(): VObject< + ObjectType<{ [K in keyof Fields]: VRequired }>, + { [K in keyof Fields]: VRequired }, + "required" + > { + const newFields: Record = {}; + for (const [key, validator] of globalThis.Object.entries(this.fields)) { + newFields[key] = validator.asRequired(); + } + return new VObject({ + isOptional: "required", + fields: newFields as { + [K in keyof Fields]: VRequired; + }, + }); + } + /** * Create a new VObject with additional fields merged in. * @param fields An object with additional validators to merge into this VObject. @@ -446,6 +520,13 @@ export class VLiteral< value: this.value, }); } + /** @internal */ + asRequired() { + return new VLiteral, "required">({ + isOptional: "required", + value: this.value as Exclude, + }); + } } /** @@ -493,6 +574,13 @@ export class VArray< element: this.element, }); } + /** @internal */ + asRequired() { + return new VArray, Element, "required">({ + isOptional: "required", + element: this.element, + }); + } } /** @@ -565,6 +653,14 @@ export class VRecord< value: this.value, }); } + /** @internal */ + asRequired() { + return new VRecord, Key, Value, "required", FieldPaths>({ + isOptional: "required", + key: this.key, + value: this.value, + }); + } } /** @@ -612,6 +708,13 @@ export class VUnion< members: this.members, }); } + /** @internal */ + asRequired() { + return new VUnion, T, "required">({ + isOptional: "required", + members: this.members, + }); + } } // prettier-ignore @@ -643,6 +746,35 @@ export type VOptional> = ? VUnion : never +// prettier-ignore +export type VRequired> = + T extends VId ? VId, "required"> + : T extends VString + ? VString, "required"> + : T extends VFloat64 + ? VFloat64, "required"> + : T extends VInt64 + ? VInt64, "required"> + : T extends VBoolean + ? VBoolean, "required"> + : T extends VNull + ? VNull, "required"> + : T extends VAny + ? VAny, "required"> + : T extends VLiteral + ? VLiteral, "required"> + : T extends VBytes + ? VBytes, "required"> + : T extends VObject< infer Type, infer Fields, OptionalProperty, infer FieldPaths> + ? VObject, Fields, "required", FieldPaths> + : T extends VArray + ? VArray, Element, "required"> + : T extends VRecord< infer Type, infer Key, infer Value, OptionalProperty, infer FieldPaths> + ? VRecord, Key, Value, "required", FieldPaths> + : T extends VUnion + ? VUnion, Members, "required", FieldPaths> + : never + /** * Type representing whether a property in an object is optional or required. * From dbd0287e4e0413639151010908d9baab5850fe20 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 17:49:19 -0600 Subject: [PATCH 2/8] add recursion to required for nested validator objects --- src/values/validator.test.ts | 35 +++++++++++++++++++++++++++-------- src/values/validators.ts | 13 +++++++++++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index f05d6f11..8051be12 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -580,7 +580,7 @@ describe("v.object utility methods", () => { expect(required.fields.any.kind).toBe("any"); }); - test("works with nested objects", () => { + test("works recursively with nested objects", () => { const original = v.object({ nested: v.optional(v.object({ inner: v.optional(v.string()), @@ -597,15 +597,23 @@ describe("v.object utility methods", () => { nested: { inner: "hello", required: 42 }, simple: 42, }; + const _test2: Result = { + nested: { required: 42 }, + simple: 42, + }; - // Runtime checks + // Runtime checks - top level expect(required.fields.nested.isOptional).toBe("required"); expect(required.fields.simple.isOptional).toBe("required"); - // The nested object fields themselves are not changed by the parent's required() + // Runtime checks - nested object fields are also made required recursively const nestedObj = required.fields.nested as any; - expect(nestedObj.fields.inner.isOptional).toBe("optional"); + expect(nestedObj.fields.inner.isOptional).toBe("required"); expect(nestedObj.fields.required.isOptional).toBe("required"); + + // Verify underlying validator types are preserved + expect(nestedObj.fields.inner.kind).toBe("string"); + expect(nestedObj.fields.required.kind).toBe("float64"); }); test("works with complex nested structures", () => { @@ -626,19 +634,30 @@ describe("v.object utility methods", () => { const required = original.required(); - // Runtime checks - only top-level fields should be made required + // Runtime checks expect(required.fields.user.isOptional).toBe("required"); expect(required.fields.metadata.isOptional).toBe("required"); expect(required.fields.tags.isOptional).toBe("required"); - // Verify nested structure is preserved + // Verify nested objects are also recursively made required const userObj = required.fields.user as any; - expect(userObj.fields.profile.isOptional).toBe("optional"); + expect(userObj.fields.profile.isOptional).toBe("required"); expect(userObj.fields.settings.isOptional).toBe("required"); const profileObj = userObj.fields.profile; - expect(profileObj.fields.name.isOptional).toBe("optional"); + expect(profileObj.fields.name.isOptional).toBe("required"); expect(profileObj.fields.age.isOptional).toBe("required"); + + const settingsObj = userObj.fields.settings; + expect(settingsObj.fields.theme.isOptional).toBe("required"); + expect(settingsObj.fields.notifications.isOptional).toBe("required"); + + // Verify underlying validator types and properties are preserved through recursion + expect(profileObj.fields.name.kind).toBe("string"); + expect(profileObj.fields.age.kind).toBe("float64"); + expect(settingsObj.fields.theme.kind).toBe("literal"); + expect((settingsObj.fields.theme as any).value).toBe("dark"); + expect(settingsObj.fields.notifications.kind).toBe("boolean"); }); test("empty object", () => { diff --git a/src/values/validators.ts b/src/values/validators.ts index a849b23e..c46ad684 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -437,7 +437,8 @@ export class VObject< } /** - * Create a new VObject with all fields marked as required & the object marked as required. + * Create a new VObject with all fields marked as required & the object marked as required. + * (Recursive for nested vObjects) */ required(): VObject< ObjectType<{ [K in keyof Fields]: VRequired }>, @@ -446,7 +447,15 @@ export class VObject< > { const newFields: Record = {}; for (const [key, validator] of globalThis.Object.entries(this.fields)) { - newFields[key] = validator.asRequired(); + if (validator.kind === "object") { + // Make required with recursion + const nestedObj = validator as VObject; + newFields[key] = nestedObj.required(); + } else if (validator.isOptional === "required") { + newFields[key] = validator; // already required + } else { + newFields[key] = validator.asRequired(); // make required + } } return new VObject({ isOptional: "required", From 355de09a492de48ea7fbf66a1ffcce88664da024 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 18:11:57 -0600 Subject: [PATCH 3/8] deep required --- src/values/validator.test.ts | 44 ++++++++++++++++++++++++++++++++++++ src/values/validators.ts | 22 ++++++++++++++---- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index 8051be12..638712c4 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -660,6 +660,50 @@ describe("v.object utility methods", () => { expect(settingsObj.fields.notifications.kind).toBe("boolean"); }); + test("recursion works with already-required nested objects", () => { + const VNestedObjectRaw = v.object({ + id: v.string(), + profile: v.object({ + displayName: v.optional(v.string()), + isPublic: v.optional(v.boolean()) + }), + tags: v.array(v.string()) + }); + + const requiredTest = VNestedObjectRaw.required(); + + // Type checks - nested fields should be required + type Result = Infer; + const _test: Result = { + id: "123", + profile: { + displayName: "John", + isPublic: true + }, + tags: ["tag1"] + }; + + const _testShouldError: Result = { + id: "123", + // @ts-expect-error - displayName should be required after recursion + profile: { + isPublic: true + // missing displayName + }, + tags: ["tag1"] + }; + + // Runtime checks - verify recursion into already-required objects + expect(requiredTest.fields.profile.isOptional).toBe("required"); + const profileObj = requiredTest.fields.profile as any; + expect(profileObj.fields.displayName.isOptional).toBe("required"); + expect(profileObj.fields.isPublic.isOptional).toBe("required"); + + // Verify other fields unchanged + expect(requiredTest.fields.id.isOptional).toBe("required"); + expect(requiredTest.fields.tags.isOptional).toBe("required"); + }); + test("empty object", () => { const original = v.object({}); const required = original.required(); diff --git a/src/values/validators.ts b/src/values/validators.ts index c46ad684..3d7a9773 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -441,8 +441,8 @@ export class VObject< * (Recursive for nested vObjects) */ required(): VObject< - ObjectType<{ [K in keyof Fields]: VRequired }>, - { [K in keyof Fields]: VRequired }, + DeepVRequired, + DeepVRequiredFields, "required" > { const newFields: Record = {}; @@ -459,9 +459,7 @@ export class VObject< } return new VObject({ isOptional: "required", - fields: newFields as { - [K in keyof Fields]: VRequired; - }, + fields: newFields as DeepVRequiredFields, }); } @@ -755,6 +753,20 @@ export type VOptional> = ? VUnion : never +// Recursive type that properly transforms nested object types +type DeepVRequired = { + [K in keyof T]-?: T[K] extends object + ? DeepVRequired + : Exclude +}; + +// Use the same approach as ObjectType but with deep transformation +type DeepVRequiredFields> = { + [K in keyof Fields]: Fields[K] extends VObject + ? VObject + : VRequired +}; + // prettier-ignore export type VRequired> = T extends VId ? VId, "required"> From 669fe62df9cc125fa4fdc0d129478e3d3ebf62db Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 18:20:47 -0600 Subject: [PATCH 4/8] update naming --- src/values/validators.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/values/validators.ts b/src/values/validators.ts index 3d7a9773..100e28d0 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -441,25 +441,23 @@ export class VObject< * (Recursive for nested vObjects) */ required(): VObject< - DeepVRequired, - DeepVRequiredFields, + DeepRequiredObjectType, + DeepRequiredObjectFields, "required" > { const newFields: Record = {}; for (const [key, validator] of globalThis.Object.entries(this.fields)) { if (validator.kind === "object") { - // Make required with recursion - const nestedObj = validator as VObject; - newFields[key] = nestedObj.required(); + newFields[key] = validator.required(); // make required with recursion } else if (validator.isOptional === "required") { newFields[key] = validator; // already required } else { - newFields[key] = validator.asRequired(); // make required + newFields[key] = validator.asRequired(); // make required with validators method } } return new VObject({ isOptional: "required", - fields: newFields as DeepVRequiredFields, + fields: newFields as DeepRequiredObjectFields, }); } @@ -753,15 +751,13 @@ export type VOptional> = ? VUnion : never -// Recursive type that properly transforms nested object types -type DeepVRequired = { +type DeepRequiredObjectType = { [K in keyof T]-?: T[K] extends object - ? DeepVRequired + ? DeepRequiredObjectType : Exclude }; -// Use the same approach as ObjectType but with deep transformation -type DeepVRequiredFields> = { +type DeepRequiredObjectFields> = { [K in keyof Fields]: Fields[K] extends VObject ? VObject : VRequired From a650ddeed4b9a196411ee93ff259cd906682f747 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 18:24:10 -0600 Subject: [PATCH 5/8] use ObjectType --- src/values/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/values/validators.ts b/src/values/validators.ts index 100e28d0..9f184c32 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -441,7 +441,7 @@ export class VObject< * (Recursive for nested vObjects) */ required(): VObject< - DeepRequiredObjectType, + ObjectType>, DeepRequiredObjectFields, "required" > { From b204228bf66ff1c04c40f6488df5a3f38f5aea2a Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 18:28:38 -0600 Subject: [PATCH 6/8] use DeepVRequired --- src/values/validator.test.ts | 2 ++ src/values/validators.ts | 18 ++++++------------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index 638712c4..8dfe5998 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -597,7 +597,9 @@ describe("v.object utility methods", () => { nested: { inner: "hello", required: 42 }, simple: 42, }; + const _test2: Result = { + // @ts-expect-error - missing required property "inner" nested: { required: 42 }, simple: 42, }; diff --git a/src/values/validators.ts b/src/values/validators.ts index 9f184c32..d26519ac 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -441,8 +441,8 @@ export class VObject< * (Recursive for nested vObjects) */ required(): VObject< - ObjectType>, - DeepRequiredObjectFields, + ObjectType>, + DeepVRequired, "required" > { const newFields: Record = {}; @@ -457,7 +457,7 @@ export class VObject< } return new VObject({ isOptional: "required", - fields: newFields as DeepRequiredObjectFields, + fields: newFields as DeepVRequired, }); } @@ -751,15 +751,9 @@ export type VOptional> = ? VUnion : never -type DeepRequiredObjectType = { - [K in keyof T]-?: T[K] extends object - ? DeepRequiredObjectType - : Exclude -}; - -type DeepRequiredObjectFields> = { - [K in keyof Fields]: Fields[K] extends VObject - ? VObject +type DeepVRequired> = { + [K in keyof Fields]: Fields[K] extends VObject + ? VObject<{ [P in keyof ObjType]-?: Exclude }, any, "required", FieldPaths> : VRequired }; From 85a8e0e378ca181b3465a741be8c30ed9d024501 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sat, 15 Nov 2025 18:34:49 -0600 Subject: [PATCH 7/8] alt --- src/values/validators.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/values/validators.ts b/src/values/validators.ts index d26519ac..b9dabd9f 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -751,9 +751,19 @@ export type VOptional> = ? VUnion : never +// type DeepVRequired> = { +// [K in keyof Fields]: Fields[K] extends VObject +// ? VObject<{ [P in keyof ObjType]-?: Exclude }, any, "required", FieldPaths> +// : VRequired +// }; type DeepVRequired> = { - [K in keyof Fields]: Fields[K] extends VObject - ? VObject<{ [P in keyof ObjType]-?: Exclude }, any, "required", FieldPaths> + [K in keyof Fields]: Fields[K] extends VObject + ? VObject< + { [P in keyof ObjType]-?: Exclude }, + DeepVRequired, + "required", + FieldPaths + > : VRequired }; From de39c2c8c2a59a3c9dbd2660501ca37a182563d0 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Sun, 16 Nov 2025 13:44:27 -0600 Subject: [PATCH 8/8] add required method to validators with object validators getting required & deepRequired --- src/values/validator.test.ts | 213 +++++++++++++++-------------------- src/values/validators.ts | 152 +++++++++++++++++-------- 2 files changed, 197 insertions(+), 168 deletions(-) diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index 8dfe5998..c31cb956 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -469,7 +469,7 @@ describe("v.object utility methods", () => { }); describe("required", () => { - test("makes all fields required", () => { + test("makes all top-level fields required", () => { const original = v.object({ a: v.optional(v.string()), b: v.optional(v.number()), @@ -518,6 +518,39 @@ describe("v.object utility methods", () => { expect(required.fields.c.isOptional).toBe("required"); }); + test("does not recurse into nested objects", () => { + const original = v.object({ + nested: v.optional(v.object({ + inner: v.optional(v.string()), + required: v.number(), + })), + simple: v.optional(v.number()), + }); + + const required = original.required(); + + // Type checks - nested.inner remains optional + type Result = Infer; + const _test: Result = { + nested: { inner: "hello", required: 42 }, + simple: 42, + }; + const _test2: Result = { + // nested.inner is still optional, so this is valid + nested: { required: 42 }, + simple: 42, + }; + + // Runtime checks - top level + expect(required.fields.nested.isOptional).toBe("required"); + expect(required.fields.simple.isOptional).toBe("required"); + + // Runtime checks - nested object fields remain unchanged (shallow) + const nestedObj = required.fields.nested; + expect(nestedObj.fields.inner.isOptional).toBe("optional"); + expect(nestedObj.fields.required.isOptional).toBe("required"); + }); + test("makes VObject itself required", () => { const original = v.object({ a: v.optional(v.string()), @@ -536,51 +569,29 @@ describe("v.object utility methods", () => { expect(required.fields.b.isOptional).toBe("required"); }); - test("works with different validator types", () => { + test("preserves validator properties", () => { const original = v.object({ - str: v.optional(v.string()), - num: v.optional(v.number()), - bool: v.optional(v.boolean()), id: v.optional(v.id("users")), - arr: v.optional(v.array(v.string())), literal: v.optional(v.literal("test")), + array: v.optional(v.array(v.string())), + record: v.optional(v.record(v.string(), v.number())), union: v.optional(v.union(v.string(), v.number())), - bytes: v.optional(v.bytes()), - int: v.optional(v.int64()), - nil: v.optional(v.null()), - any: v.optional(v.any()), }); const required = original.required(); - // Runtime checks - all should be required - expect(required.fields.str.isOptional).toBe("required"); - expect(required.fields.num.isOptional).toBe("required"); - expect(required.fields.bool.isOptional).toBe("required"); - expect(required.fields.id.isOptional).toBe("required"); - expect(required.fields.arr.isOptional).toBe("required"); - expect(required.fields.literal.isOptional).toBe("required"); - expect(required.fields.union.isOptional).toBe("required"); - expect(required.fields.bytes.isOptional).toBe("required"); - expect(required.fields.int.isOptional).toBe("required"); - expect(required.fields.nil.isOptional).toBe("required"); - expect(required.fields.any.isOptional).toBe("required"); - - // Verify validator kinds are preserved - expect(required.fields.str.kind).toBe("string"); - expect(required.fields.num.kind).toBe("float64"); - expect(required.fields.bool.kind).toBe("boolean"); - expect(required.fields.id.kind).toBe("id"); - expect(required.fields.arr.kind).toBe("array"); - expect(required.fields.literal.kind).toBe("literal"); - expect(required.fields.union.kind).toBe("union"); - expect(required.fields.bytes.kind).toBe("bytes"); - expect(required.fields.int.kind).toBe("int64"); - expect(required.fields.nil.kind).toBe("null"); - expect(required.fields.any.kind).toBe("any"); - }); - - test("works recursively with nested objects", () => { + // Check that specific validator properties are preserved + expect((required.fields.id).tableName).toBe("users"); + expect((required.fields.literal).value).toBe("test"); + expect((required.fields.array).element.kind).toBe("string"); + expect((required.fields.record).key.kind).toBe("string"); + expect((required.fields.record).value.kind).toBe("float64"); + expect((required.fields.union).members).toHaveLength(2); + }); + }); + + describe("deepRequired", () => { + test("recursively makes all fields required including nested objects", () => { const original = v.object({ nested: v.optional(v.object({ inner: v.optional(v.string()), @@ -589,17 +600,16 @@ describe("v.object utility methods", () => { simple: v.optional(v.number()), }); - const required = original.required(); + const required = original.deepRequired(); - // Type checks + // Type checks - nested.inner becomes required type Result = Infer; const _test: Result = { nested: { inner: "hello", required: 42 }, simple: 42, }; - const _test2: Result = { - // @ts-expect-error - missing required property "inner" + // @ts-expect-error - missing required property "inner" nested: { required: 42 }, simple: 42, }; @@ -609,61 +619,32 @@ describe("v.object utility methods", () => { expect(required.fields.simple.isOptional).toBe("required"); // Runtime checks - nested object fields are also made required recursively - const nestedObj = required.fields.nested as any; + const nestedObj = required.fields.nested; expect(nestedObj.fields.inner.isOptional).toBe("required"); expect(nestedObj.fields.required.isOptional).toBe("required"); - - // Verify underlying validator types are preserved - expect(nestedObj.fields.inner.kind).toBe("string"); - expect(nestedObj.fields.required.kind).toBe("float64"); }); - test("works with complex nested structures", () => { + test("works with multiple levels of nesting", () => { const original = v.object({ - user: v.optional(v.object({ - profile: v.optional(v.object({ - name: v.optional(v.string()), - age: v.number(), + level1: v.optional(v.object({ + level2: v.optional(v.object({ + level3: v.optional(v.string()), })), - settings: v.object({ - theme: v.optional(v.literal("dark")), - notifications: v.boolean(), - }), })), - metadata: v.optional(v.record(v.string(), v.any())), - tags: v.optional(v.array(v.string())), }); - const required = original.required(); + const required = original.deepRequired(); - // Runtime checks - expect(required.fields.user.isOptional).toBe("required"); - expect(required.fields.metadata.isOptional).toBe("required"); - expect(required.fields.tags.isOptional).toBe("required"); - - // Verify nested objects are also recursively made required - const userObj = required.fields.user as any; - expect(userObj.fields.profile.isOptional).toBe("required"); - expect(userObj.fields.settings.isOptional).toBe("required"); - - const profileObj = userObj.fields.profile; - expect(profileObj.fields.name.isOptional).toBe("required"); - expect(profileObj.fields.age.isOptional).toBe("required"); - - const settingsObj = userObj.fields.settings; - expect(settingsObj.fields.theme.isOptional).toBe("required"); - expect(settingsObj.fields.notifications.isOptional).toBe("required"); - - // Verify underlying validator types and properties are preserved through recursion - expect(profileObj.fields.name.kind).toBe("string"); - expect(profileObj.fields.age.kind).toBe("float64"); - expect(settingsObj.fields.theme.kind).toBe("literal"); - expect((settingsObj.fields.theme as any).value).toBe("dark"); - expect(settingsObj.fields.notifications.kind).toBe("boolean"); + // Runtime checks - all levels become required + const level1 = required.fields.level1; + const level2 = level1.fields.level2; + expect(level1.isOptional).toBe("required"); + expect(level2.isOptional).toBe("required"); + expect(level2.fields.level3.isOptional).toBe("required"); }); test("recursion works with already-required nested objects", () => { - const VNestedObjectRaw = v.object({ + const original = v.object({ id: v.string(), profile: v.object({ displayName: v.optional(v.string()), @@ -672,10 +653,10 @@ describe("v.object utility methods", () => { tags: v.array(v.string()) }); - const requiredTest = VNestedObjectRaw.required(); + const required = original.deepRequired(); // Type checks - nested fields should be required - type Result = Infer; + type Result = Infer; const _test: Result = { id: "123", profile: { @@ -696,46 +677,38 @@ describe("v.object utility methods", () => { }; // Runtime checks - verify recursion into already-required objects - expect(requiredTest.fields.profile.isOptional).toBe("required"); - const profileObj = requiredTest.fields.profile as any; + expect(required.fields.profile.isOptional).toBe("required"); + const profileObj = required.fields.profile; expect(profileObj.fields.displayName.isOptional).toBe("required"); expect(profileObj.fields.isPublic.isOptional).toBe("required"); - - // Verify other fields unchanged - expect(requiredTest.fields.id.isOptional).toBe("required"); - expect(requiredTest.fields.tags.isOptional).toBe("required"); - }); - - test("empty object", () => { - const original = v.object({}); - const required = original.required(); - - // Type checks - assert, {}>>(); - - // Runtime checks - expect(required.fields).toEqual({}); - expect(required.isOptional).toBe("required"); }); + }); - test("preserves validator properties", () => { + describe("required vs deepRequired", () => { + test("required is shallow, deepRequired is deep", () => { const original = v.object({ - id: v.optional(v.id("users")), - literal: v.optional(v.literal("test")), - array: v.optional(v.array(v.string())), - record: v.optional(v.record(v.string(), v.number())), - union: v.optional(v.union(v.string(), v.number())), + a: v.optional(v.string()), + nested: v.optional(v.object({ + inner: v.optional(v.string()), + })), }); - const required = original.required(); + const shallow = original.required(); + const deep = original.deepRequired(); - // Check that specific validator properties are preserved - expect((required.fields.id as any).tableName).toBe("users"); - expect((required.fields.literal as any).value).toBe("test"); - expect((required.fields.array as any).element.kind).toBe("string"); - expect((required.fields.record as any).key.kind).toBe("string"); - expect((required.fields.record as any).value.kind).toBe("float64"); - expect((required.fields.union as any).members).toHaveLength(2); + // Both make top-level required + expect(shallow.fields.a.isOptional).toBe("required"); + expect(deep.fields.a.isOptional).toBe("required"); + expect(shallow.fields.nested.isOptional).toBe("required"); + expect(deep.fields.nested.isOptional).toBe("required"); + + // Shallow: nested fields stay optional + const shallowNested = shallow.fields.nested; + expect(shallowNested.fields.inner.isOptional).toBe("optional"); + + // Deep: nested fields become required + const deepNested = deep.fields.nested; + expect(deepNested.fields.inner.isOptional).toBe("required"); }); }); @@ -762,7 +735,7 @@ describe("v.object utility methods", () => { }); describe("asRequired vs required", () => { - test("asRequired only affects object, required affects both", () => { + test("asRequired only affects object, required affects fields", () => { const original = v.object({ a: v.string(), b: v.optional(v.number()), @@ -777,7 +750,7 @@ describe("v.object utility methods", () => { expect(asRequired.fields.a.isOptional).toBe("required"); expect(asRequired.fields.b.isOptional).toBe("optional"); - // required: both object and fields become required + // required: both object and top-level fields become required expect(required.isOptional).toBe("required"); expect(required.fields.a.isOptional).toBe("required"); expect(required.fields.b.isOptional).toBe("required"); diff --git a/src/values/validators.ts b/src/values/validators.ts index b9dabd9f..4a424fc9 100644 --- a/src/values/validators.ts +++ b/src/values/validators.ts @@ -46,6 +46,8 @@ abstract class BaseValidator< abstract asOptional(): Validator; /** @internal */ abstract asRequired(): Validator, "required", FieldPaths>; + /** @internal */ + abstract asRequired(): Validator, "required", FieldPaths>; } /** @@ -99,6 +101,13 @@ export class VId< tableName: this.tableName, }); } + /** @internal */ + asRequired() { + return new VId, "required">({ + isOptional: "required", + tableName: this.tableName, + }); + } } /** @@ -130,6 +139,12 @@ export class VFloat64< isOptional: "required", }); } + /** @internal */ + asRequired() { + return new VFloat64, "required">({ + isOptional: "required", + }); + } } /** @@ -157,6 +172,10 @@ export class VInt64< asRequired() { return new VInt64, "required">({ isOptional: "required" }); } + /** @internal */ + asRequired() { + return new VInt64, "required">({ isOptional: "required" }); + } } /** @@ -187,6 +206,12 @@ export class VBoolean< isOptional: "required", }); } + /** @internal */ + asRequired() { + return new VBoolean, "required">({ + isOptional: "required", + }); + } } /** @@ -213,6 +238,10 @@ export class VBytes< asRequired() { return new VBytes, "required">({ isOptional: "required" }); } + /** @internal */ + asRequired() { + return new VBytes, "required">({ isOptional: "required" }); + } } /** @@ -243,6 +272,12 @@ export class VString< isOptional: "required", }); } + /** @internal */ + asRequired() { + return new VString, "required">({ + isOptional: "required", + }); + } } /** @@ -269,6 +304,10 @@ export class VNull< asRequired() { return new VNull, "required">({ isOptional: "required" }); } + /** @internal */ + asRequired() { + return new VNull, "required">({ isOptional: "required" }); + } } /** @@ -302,6 +341,12 @@ export class VAny< isOptional: "required", }); } + /** @internal */ + asRequired() { + return new VAny, "required", FieldPaths>({ + isOptional: "required", + }); + } } /** @@ -363,6 +408,7 @@ export class VObject< } /** @internal */ /** Only marks the object as optional. Fields are left unchanged. If you want the fields to be optional use .partial() */ + /** Only marks the object as optional. Fields are left unchanged. If you want the fields to be optional use .partial() */ asOptional() { return new VObject({ isOptional: "optional", @@ -377,6 +423,14 @@ export class VObject< fields: this.fields, }); } + /** @internal */ + /** Only marks the object as required. Fields are left unchanged. If you want the fields and object to be required use .required() */ + asRequired() { + return new VObject, Fields, "required", FieldPaths>({ + isOptional: "required", + fields: this.fields, + }); + } /** * Create a new VObject with the specified fields omitted. @@ -437,10 +491,35 @@ export class VObject< } /** - * Create a new VObject with all fields marked as required & the object marked as required. - * (Recursive for nested vObjects) + * Create a new VObject with all top-level fields marked as required & the object marked as required. + * Nested objects are not affected (shallow operation). + * + * See also {@link deepRequired} for a recursive (deep) version of this method. */ required(): VObject< + ObjectType<{ [K in keyof Fields]: VRequired }>, + { [K in keyof Fields]: VRequired }, + "required" + > { + const newFields: Record = {}; + for (const [key, validator] of globalThis.Object.entries(this.fields)) { + if (validator.isOptional === "required") { + newFields[key] = validator; // already required + } else { + newFields[key] = validator.asRequired(); // make required with validators method + } + } + return new VObject({ + isOptional: "required", + fields: newFields as { [K in keyof Fields]: VRequired }, + }); + } + + /** + * Create a new VObject with all fields marked as required & the object marked as required. + * Recursively makes nested objects required (deep operation). + */ + deepRequired(): VObject< ObjectType>, DeepVRequired, "required" @@ -448,7 +527,7 @@ export class VObject< const newFields: Record = {}; for (const [key, validator] of globalThis.Object.entries(this.fields)) { if (validator.kind === "object") { - newFields[key] = validator.required(); // make required with recursion + newFields[key] = validator.deepRequired(); // make required with recursion } else if (validator.isOptional === "required") { newFields[key] = validator; // already required } else { @@ -532,6 +611,13 @@ export class VLiteral< value: this.value as Exclude, }); } + /** @internal */ + asRequired() { + return new VLiteral, "required">({ + isOptional: "required", + value: this.value as Exclude, + }); + } } /** @@ -586,6 +672,13 @@ export class VArray< element: this.element, }); } + /** @internal */ + asRequired() { + return new VArray, Element, "required">({ + isOptional: "required", + element: this.element, + }); + } } /** @@ -666,6 +759,14 @@ export class VRecord< value: this.value, }); } + /** @internal */ + asRequired() { + return new VRecord, Key, Value, "required", FieldPaths>({ + isOptional: "required", + key: this.key, + value: this.value, + }); + } } /** @@ -751,51 +852,6 @@ export type VOptional> = ? VUnion : never -// type DeepVRequired> = { -// [K in keyof Fields]: Fields[K] extends VObject -// ? VObject<{ [P in keyof ObjType]-?: Exclude }, any, "required", FieldPaths> -// : VRequired -// }; -type DeepVRequired> = { - [K in keyof Fields]: Fields[K] extends VObject - ? VObject< - { [P in keyof ObjType]-?: Exclude }, - DeepVRequired, - "required", - FieldPaths - > - : VRequired -}; - -// prettier-ignore -export type VRequired> = - T extends VId ? VId, "required"> - : T extends VString - ? VString, "required"> - : T extends VFloat64 - ? VFloat64, "required"> - : T extends VInt64 - ? VInt64, "required"> - : T extends VBoolean - ? VBoolean, "required"> - : T extends VNull - ? VNull, "required"> - : T extends VAny - ? VAny, "required"> - : T extends VLiteral - ? VLiteral, "required"> - : T extends VBytes - ? VBytes, "required"> - : T extends VObject< infer Type, infer Fields, OptionalProperty, infer FieldPaths> - ? VObject, Fields, "required", FieldPaths> - : T extends VArray - ? VArray, Element, "required"> - : T extends VRecord< infer Type, infer Key, infer Value, OptionalProperty, infer FieldPaths> - ? VRecord, Key, Value, "required", FieldPaths> - : T extends VUnion - ? VUnion, Members, "required", FieldPaths> - : never - /** * Type representing whether a property in an object is optional or required. *