Skip to content

Commit fdb9f6c

Browse files
committed
add required method to validators with objects getting deepRequired option
1 parent 60d58c4 commit fdb9f6c

File tree

2 files changed

+571
-0
lines changed

2 files changed

+571
-0
lines changed

src/values/validator.test.ts

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,295 @@ describe("v.object utility methods", () => {
468468
});
469469
});
470470

471+
describe("required", () => {
472+
test("makes all top-level fields required", () => {
473+
const original = v.object({
474+
a: v.optional(v.string()),
475+
b: v.optional(v.number()),
476+
c: v.optional(v.boolean()),
477+
});
478+
479+
const required = original.required();
480+
481+
// Type checks
482+
assert<
483+
Equals<
484+
Infer<typeof required>,
485+
{
486+
a: string;
487+
b: number;
488+
c: boolean;
489+
}
490+
>
491+
>();
492+
493+
// Runtime checks
494+
expect(required.fields.a.isOptional).toBe("required");
495+
expect(required.fields.b.isOptional).toBe("required");
496+
expect(required.fields.c.isOptional).toBe("required");
497+
expect(required.isOptional).toBe("required");
498+
});
499+
500+
test("works with already required fields", () => {
501+
const original = v.object({
502+
a: v.string(),
503+
b: v.optional(v.number()),
504+
c: v.boolean(),
505+
});
506+
507+
const required = original.required();
508+
509+
// Type checks - all fields should be required
510+
type Result = Infer<typeof required>;
511+
const _test1: Result = { a: "hello", b: 42, c: true };
512+
// @ts-expect-error - fields should not be optional
513+
const _test2: Result = { a: "hello" };
514+
515+
// Runtime checks
516+
expect(required.fields.a.isOptional).toBe("required");
517+
expect(required.fields.b.isOptional).toBe("required");
518+
expect(required.fields.c.isOptional).toBe("required");
519+
});
520+
521+
test("does not recurse into nested objects", () => {
522+
const original = v.object({
523+
nested: v.optional(v.object({
524+
inner: v.optional(v.string()),
525+
required: v.number(),
526+
})),
527+
simple: v.optional(v.number()),
528+
});
529+
530+
const required = original.required();
531+
532+
// Type checks - nested.inner remains optional
533+
type Result = Infer<typeof required>;
534+
const _test: Result = {
535+
nested: { inner: "hello", required: 42 },
536+
simple: 42,
537+
};
538+
const _test2: Result = {
539+
// nested.inner is still optional, so this is valid
540+
nested: { required: 42 },
541+
simple: 42,
542+
};
543+
544+
// Runtime checks - top level
545+
expect(required.fields.nested.isOptional).toBe("required");
546+
expect(required.fields.simple.isOptional).toBe("required");
547+
548+
// Runtime checks - nested object fields remain unchanged (shallow)
549+
const nestedObj = required.fields.nested;
550+
expect(nestedObj.fields.inner.isOptional).toBe("optional");
551+
expect(nestedObj.fields.required.isOptional).toBe("required");
552+
});
553+
554+
test("makes VObject itself required", () => {
555+
const original = v.object({
556+
a: v.optional(v.string()),
557+
b: v.optional(v.number()),
558+
});
559+
const optional = original.asOptional();
560+
const required = optional.required();
561+
562+
// Type checks
563+
type Result = Infer<typeof required>;
564+
const _test: Result = { a: "hello", b: 42 };
565+
566+
// Runtime check: Both VObject and fields become required
567+
expect(required.isOptional).toBe("required");
568+
expect(required.fields.a.isOptional).toBe("required");
569+
expect(required.fields.b.isOptional).toBe("required");
570+
});
571+
572+
test("preserves validator properties", () => {
573+
const original = v.object({
574+
id: v.optional(v.id("users")),
575+
literal: v.optional(v.literal("test")),
576+
array: v.optional(v.array(v.string())),
577+
record: v.optional(v.record(v.string(), v.number())),
578+
union: v.optional(v.union(v.string(), v.number())),
579+
});
580+
581+
const required = original.required();
582+
583+
// Check that specific validator properties are preserved
584+
expect((required.fields.id).tableName).toBe("users");
585+
expect((required.fields.literal).value).toBe("test");
586+
expect((required.fields.array).element.kind).toBe("string");
587+
expect((required.fields.record).key.kind).toBe("string");
588+
expect((required.fields.record).value.kind).toBe("float64");
589+
expect((required.fields.union).members).toHaveLength(2);
590+
});
591+
});
592+
593+
describe("deepRequired", () => {
594+
test("recursively makes all fields required including nested objects", () => {
595+
const original = v.object({
596+
nested: v.optional(v.object({
597+
inner: v.optional(v.string()),
598+
required: v.number(),
599+
})),
600+
simple: v.optional(v.number()),
601+
});
602+
603+
const required = original.deepRequired();
604+
605+
// Type checks - nested.inner becomes required
606+
type Result = Infer<typeof required>;
607+
const _test: Result = {
608+
nested: { inner: "hello", required: 42 },
609+
simple: 42,
610+
};
611+
const _test2: Result = {
612+
// @ts-expect-error - missing required property "inner"
613+
nested: { required: 42 },
614+
simple: 42,
615+
};
616+
617+
// Runtime checks - top level
618+
expect(required.fields.nested.isOptional).toBe("required");
619+
expect(required.fields.simple.isOptional).toBe("required");
620+
621+
// Runtime checks - nested object fields are also made required recursively
622+
const nestedObj = required.fields.nested;
623+
expect(nestedObj.fields.inner.isOptional).toBe("required");
624+
expect(nestedObj.fields.required.isOptional).toBe("required");
625+
});
626+
627+
test("works with multiple levels of nesting", () => {
628+
const original = v.object({
629+
level1: v.optional(v.object({
630+
level2: v.optional(v.object({
631+
level3: v.optional(v.string()),
632+
})),
633+
})),
634+
});
635+
636+
const required = original.deepRequired();
637+
638+
// Runtime checks - all levels become required
639+
const level1 = required.fields.level1;
640+
const level2 = level1.fields.level2;
641+
expect(level1.isOptional).toBe("required");
642+
expect(level2.isOptional).toBe("required");
643+
expect(level2.fields.level3.isOptional).toBe("required");
644+
});
645+
646+
test("recursion works with already-required nested objects", () => {
647+
const original = v.object({
648+
id: v.string(),
649+
profile: v.object({
650+
displayName: v.optional(v.string()),
651+
isPublic: v.optional(v.boolean())
652+
}),
653+
tags: v.array(v.string())
654+
});
655+
656+
const required = original.deepRequired();
657+
658+
// Type checks - nested fields should be required
659+
type Result = Infer<typeof required>;
660+
const _test: Result = {
661+
id: "123",
662+
profile: {
663+
displayName: "John",
664+
isPublic: true
665+
},
666+
tags: ["tag1"]
667+
};
668+
669+
const _testShouldError: Result = {
670+
id: "123",
671+
// @ts-expect-error - displayName should be required after recursion
672+
profile: {
673+
isPublic: true
674+
// missing displayName
675+
},
676+
tags: ["tag1"]
677+
};
678+
679+
// Runtime checks - verify recursion into already-required objects
680+
expect(required.fields.profile.isOptional).toBe("required");
681+
const profileObj = required.fields.profile;
682+
expect(profileObj.fields.displayName.isOptional).toBe("required");
683+
expect(profileObj.fields.isPublic.isOptional).toBe("required");
684+
});
685+
});
686+
687+
describe("required vs deepRequired", () => {
688+
test("required is shallow, deepRequired is deep", () => {
689+
const original = v.object({
690+
a: v.optional(v.string()),
691+
nested: v.optional(v.object({
692+
inner: v.optional(v.string()),
693+
})),
694+
});
695+
696+
const shallow = original.required();
697+
const deep = original.deepRequired();
698+
699+
// Both make top-level required
700+
expect(shallow.fields.a.isOptional).toBe("required");
701+
expect(deep.fields.a.isOptional).toBe("required");
702+
expect(shallow.fields.nested.isOptional).toBe("required");
703+
expect(deep.fields.nested.isOptional).toBe("required");
704+
705+
// Shallow: nested fields stay optional
706+
const shallowNested = shallow.fields.nested;
707+
expect(shallowNested.fields.inner.isOptional).toBe("optional");
708+
709+
// Deep: nested fields become required
710+
const deepNested = deep.fields.nested;
711+
expect(deepNested.fields.inner.isOptional).toBe("required");
712+
});
713+
});
714+
715+
describe("asOptional vs partial", () => {
716+
test("asOptional only affects object, partial affects fields", () => {
717+
const original = v.object({
718+
a: v.string(),
719+
b: v.optional(v.number()),
720+
});
721+
722+
const asOptional = original.asOptional();
723+
const partial = original.partial();
724+
725+
// asOptional: only object becomes optional, fields unchanged
726+
expect(asOptional.isOptional).toBe("optional");
727+
expect(asOptional.fields.a.isOptional).toBe("required");
728+
expect(asOptional.fields.b.isOptional).toBe("optional");
729+
730+
// partial: object unchanged, all fields become optional
731+
expect(partial.isOptional).toBe("required");
732+
expect(partial.fields.a.isOptional).toBe("optional");
733+
expect(partial.fields.b.isOptional).toBe("optional");
734+
});
735+
});
736+
737+
describe("asRequired vs required", () => {
738+
test("asRequired only affects object, required affects fields", () => {
739+
const original = v.object({
740+
a: v.string(),
741+
b: v.optional(v.number()),
742+
});
743+
const optional = original.asOptional();
744+
745+
const asRequired = optional.asRequired();
746+
const required = optional.required();
747+
748+
// asRequired: only object becomes required, fields unchanged
749+
expect(asRequired.isOptional).toBe("required");
750+
expect(asRequired.fields.a.isOptional).toBe("required");
751+
expect(asRequired.fields.b.isOptional).toBe("optional");
752+
753+
// required: both object and top-level fields become required
754+
expect(required.isOptional).toBe("required");
755+
expect(required.fields.a.isOptional).toBe("required");
756+
expect(required.fields.b.isOptional).toBe("required");
757+
});
758+
});
759+
471760
describe("chaining utility methods", () => {
472761
test("can chain multiple operations", () => {
473762
const base = v.object({
@@ -499,6 +788,79 @@ describe("v.object utility methods", () => {
499788
expect(result.fields.a.isOptional).toBe("optional");
500789
});
501790

791+
test("can chain operations including required()", () => {
792+
const base = v.object({
793+
a: v.optional(v.string()),
794+
b: v.optional(v.number()),
795+
c: v.optional(v.boolean()),
796+
d: v.optional(v.int64()),
797+
});
798+
799+
const result = base.required().omit("d").extend({ e: v.optional(v.bytes()) });
800+
801+
// Type checks
802+
type Result = Infer<typeof result>;
803+
const _test1: Result = {
804+
a: "hello",
805+
b: 42,
806+
c: true,
807+
e: new ArrayBuffer(0),
808+
};
809+
const _test2: Result = {
810+
a: "hello",
811+
b: 42,
812+
c: true,
813+
// e is optional
814+
};
815+
816+
// Runtime checks
817+
expect(result.fields).toHaveProperty("a");
818+
expect(result.fields).toHaveProperty("b");
819+
expect(result.fields).toHaveProperty("c");
820+
expect(result.fields).toHaveProperty("e");
821+
expect(result.fields).not.toHaveProperty("d");
822+
823+
// Original fields became required, new field is optional
824+
expect(result.fields.a.isOptional).toBe("required");
825+
expect(result.fields.b.isOptional).toBe("required");
826+
expect(result.fields.c.isOptional).toBe("required");
827+
expect(result.fields.e.isOptional).toBe("optional");
828+
});
829+
830+
test("required() in complex chain", () => {
831+
const base = v.object({
832+
keep: v.string(),
833+
remove: v.number(),
834+
makeOptional: v.boolean(),
835+
});
836+
837+
// partial -> pick -> extend -> required
838+
const result = base
839+
.partial()
840+
.pick("keep", "makeOptional")
841+
.extend({
842+
newRequired: v.string(),
843+
newOptional: v.optional(v.number()),
844+
})
845+
.required();
846+
847+
// Type checks
848+
type Result = Infer<typeof result>;
849+
const _test: Result = {
850+
keep: "hello",
851+
makeOptional: true,
852+
newRequired: "world",
853+
newOptional: 42,
854+
};
855+
856+
// Runtime checks
857+
expect(result.fields.keep.isOptional).toBe("required");
858+
expect(result.fields.makeOptional.isOptional).toBe("required");
859+
expect(result.fields.newRequired.isOptional).toBe("required");
860+
expect(result.fields.newOptional.isOptional).toBe("required");
861+
expect(result.fields).not.toHaveProperty("remove");
862+
});
863+
502864
test("complex chaining scenario", () => {
503865
const user = v.object({
504866
name: v.string(),

0 commit comments

Comments
 (0)