diff --git a/src/values/validator.test.ts b/src/values/validator.test.ts index e6aac4a..c31cb95 100644 --- a/src/values/validator.test.ts +++ b/src/values/validator.test.ts @@ -468,6 +468,295 @@ describe("v.object utility methods", () => { }); }); + describe("required", () => { + test("makes all top-level 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("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()), + 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("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).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()), + required: v.number(), + })), + simple: v.optional(v.number()), + }); + + const required = original.deepRequired(); + + // 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" + 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 are also made required recursively + const nestedObj = required.fields.nested; + expect(nestedObj.fields.inner.isOptional).toBe("required"); + expect(nestedObj.fields.required.isOptional).toBe("required"); + }); + + test("works with multiple levels of nesting", () => { + const original = v.object({ + level1: v.optional(v.object({ + level2: v.optional(v.object({ + level3: v.optional(v.string()), + })), + })), + }); + + const required = original.deepRequired(); + + // 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 original = v.object({ + id: v.string(), + profile: v.object({ + displayName: v.optional(v.string()), + isPublic: v.optional(v.boolean()) + }), + tags: v.array(v.string()) + }); + + const required = original.deepRequired(); + + // 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(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"); + }); + }); + + describe("required vs deepRequired", () => { + test("required is shallow, deepRequired is deep", () => { + const original = v.object({ + a: v.optional(v.string()), + nested: v.optional(v.object({ + inner: v.optional(v.string()), + })), + }); + + const shallow = original.required(); + const deep = original.deepRequired(); + + // 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"); + }); + }); + + 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 fields", () => { + 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 top-level 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 +788,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 54f6ef1..3bfc675 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() or .deepRequired() */ + asRequired() { + return new VObject, Fields, "required", FieldPaths>({ + isOptional: "required", + fields: this.fields, + }); + } /** * Create a new VObject with the specified fields omitted. @@ -382,6 +436,56 @@ export class VObject< }); } + /** + * 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" + > { + const newFields: Record = {}; + for (const [key, validator] of globalThis.Object.entries(this.fields)) { + if (validator.kind === "object") { + newFields[key] = validator.deepRequired(); // make required with recursion + } else 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 DeepVRequired, + }); + } + /** * Create a new VObject with additional fields merged in. * @param fields An object with additional validators to merge into this VObject. @@ -446,6 +550,13 @@ export class VLiteral< value: this.value, }); } + /** @internal */ + asRequired() { + return new VLiteral, "required">({ + isOptional: "required", + value: this.value as Exclude, + }); + } } /** @@ -493,6 +604,13 @@ export class VArray< element: this.element, }); } + /** @internal */ + asRequired() { + return new VArray, Element, "required">({ + isOptional: "required", + element: this.element, + }); + } } /** @@ -565,6 +683,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 +738,13 @@ export class VUnion< members: this.members, }); } + /** @internal */ + asRequired() { + return new VUnion, T, "required">({ + isOptional: "required", + members: this.members, + }); + } } // prettier-ignore @@ -643,6 +776,46 @@ 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 DeepVRequired> = { + [K in keyof Fields]: Fields[K] extends VObject + ? VObject< + { [P in keyof ObjType]-?: Exclude }, + DeepVRequired, + "required", + FieldPaths + > + : VRequired +}; + /** * Type representing whether a property in an object is optional or required. *