632 lines
14 KiB
Plaintext
632 lines
14 KiB
Plaintext
import { expect, expectTypeOf, test } from "vitest";
|
|
import { z } from "zod/v4";
|
|
|
|
test("recursion with z.lazy", () => {
|
|
const data = {
|
|
name: "I",
|
|
subcategories: [
|
|
{
|
|
name: "A",
|
|
subcategories: [
|
|
{
|
|
name: "1",
|
|
subcategories: [
|
|
{
|
|
name: "a",
|
|
subcategories: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const Category = z.object({
|
|
name: z.string(),
|
|
get subcategories() {
|
|
return z.array(Category).optional().nullable();
|
|
},
|
|
});
|
|
type Category = z.infer<typeof Category>;
|
|
interface _Category {
|
|
name: string;
|
|
subcategories?: _Category[] | undefined | null;
|
|
}
|
|
expectTypeOf<Category>().toEqualTypeOf<_Category>();
|
|
Category.parse(data);
|
|
});
|
|
|
|
test("recursion involving union type", () => {
|
|
const data = {
|
|
value: 1,
|
|
next: {
|
|
value: 2,
|
|
next: {
|
|
value: 3,
|
|
next: {
|
|
value: 4,
|
|
next: null,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const LL = z.object({
|
|
value: z.number(),
|
|
get next() {
|
|
return LL.nullable();
|
|
},
|
|
});
|
|
type LL = z.infer<typeof LL>;
|
|
type _LL = {
|
|
value: number;
|
|
next: _LL | null;
|
|
};
|
|
expectTypeOf<LL>().toEqualTypeOf<_LL>();
|
|
|
|
LL.parse(data);
|
|
});
|
|
|
|
test("mutual recursion - native", () => {
|
|
const Alazy = z.object({
|
|
val: z.number(),
|
|
get b() {
|
|
return Blazy;
|
|
},
|
|
});
|
|
|
|
const Blazy = z.object({
|
|
val: z.number(),
|
|
get a() {
|
|
return Alazy.optional();
|
|
},
|
|
});
|
|
const testData = {
|
|
val: 1,
|
|
b: {
|
|
val: 5,
|
|
a: {
|
|
val: 3,
|
|
b: {
|
|
val: 4,
|
|
a: {
|
|
val: 2,
|
|
b: {
|
|
val: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
type Alazy = z.infer<typeof Alazy>;
|
|
type Blazy = z.infer<typeof Blazy>;
|
|
interface _Alazy {
|
|
val: number;
|
|
b: _Blazy;
|
|
}
|
|
interface _Blazy {
|
|
val: number;
|
|
a?: _Alazy | undefined;
|
|
}
|
|
expectTypeOf<Alazy>().toEqualTypeOf<_Alazy>();
|
|
expectTypeOf<Blazy>().toEqualTypeOf<_Blazy>();
|
|
Alazy.parse(testData);
|
|
Blazy.parse(testData.b);
|
|
|
|
expect(() => Alazy.parse({ val: "asdf" })).toThrow();
|
|
});
|
|
|
|
test("pick and omit with getter", () => {
|
|
const Category = z.strictObject({
|
|
name: z.string(),
|
|
get subcategories() {
|
|
return z.array(Category);
|
|
},
|
|
});
|
|
|
|
type Category = z.infer<typeof Category>;
|
|
|
|
interface _Category {
|
|
name: string;
|
|
subcategories: _Category[];
|
|
}
|
|
expectTypeOf<Category>().toEqualTypeOf<_Category>();
|
|
|
|
// Shape should not surface `readonly` modifiers from getter-defined keys.
|
|
// object/strictObject/looseObject all pass shape through util.Writeable<T>.
|
|
type Shape = (typeof Category)["shape"];
|
|
expectTypeOf<Shape>().toEqualTypeOf<{ name: z.ZodString; subcategories: z.ZodArray<typeof Category> }>();
|
|
|
|
const PickedCategory = Category.pick({ name: true });
|
|
const OmittedCategory = Category.omit({ subcategories: true });
|
|
|
|
const picked = { name: "test" };
|
|
const omitted = { name: "test" };
|
|
|
|
PickedCategory.parse(picked);
|
|
OmittedCategory.parse(omitted);
|
|
|
|
expect(() => PickedCategory.parse({ name: "test", subcategories: [] })).toThrow();
|
|
expect(() => OmittedCategory.parse({ name: "test", subcategories: [] })).toThrow();
|
|
});
|
|
|
|
test("shape stays writeable through extend/safeExtend/partial/required", () => {
|
|
const Base = z.object({ name: z.string() });
|
|
|
|
const Extended = Base.extend({
|
|
get sub() {
|
|
return z.array(Extended);
|
|
},
|
|
});
|
|
type ExtendedShape = (typeof Extended)["shape"];
|
|
expectTypeOf<ExtendedShape>().toEqualTypeOf<{
|
|
name: z.ZodString;
|
|
sub: z.ZodArray<typeof Extended>;
|
|
}>();
|
|
|
|
const SafeExt = Base.safeExtend({ extra: z.string() } as const);
|
|
type SafeExtShape = (typeof SafeExt)["shape"];
|
|
expectTypeOf<SafeExtShape>().toEqualTypeOf<{
|
|
name: z.ZodString;
|
|
extra: z.ZodString;
|
|
}>();
|
|
|
|
const FromConst = Base.extend({ a: z.string(), b: z.number() } as const);
|
|
type FromConstShape = (typeof FromConst)["shape"];
|
|
expectTypeOf<FromConstShape>().toEqualTypeOf<{
|
|
name: z.ZodString;
|
|
a: z.ZodString;
|
|
b: z.ZodNumber;
|
|
}>();
|
|
|
|
const PartialExtended = Extended.partial();
|
|
type PartialShape = (typeof PartialExtended)["shape"];
|
|
expectTypeOf<PartialShape>().toEqualTypeOf<{
|
|
name: z.ZodOptional<z.ZodString>;
|
|
sub: z.ZodOptional<z.ZodArray<typeof Extended>>;
|
|
}>();
|
|
|
|
const RequiredExtended = Extended.required();
|
|
type RequiredShape = (typeof RequiredExtended)["shape"];
|
|
expectTypeOf<RequiredShape>().toEqualTypeOf<{
|
|
name: z.ZodNonOptional<z.ZodString>;
|
|
sub: z.ZodNonOptional<z.ZodArray<typeof Extended>>;
|
|
}>();
|
|
});
|
|
|
|
test("deferred self-recursion", () => {
|
|
const Feature = z.object({
|
|
title: z.string(),
|
|
get features(): z.ZodOptional<z.ZodArray<typeof Feature>> {
|
|
return z.optional(z.array(Feature)); //.optional();
|
|
},
|
|
});
|
|
// type Feature = z.infer<typeof Feature>;
|
|
|
|
const Output = z.object({
|
|
id: z.int(), //.nonnegative(),
|
|
name: z.string(),
|
|
get features(): z.ZodArray<typeof Feature> {
|
|
return Feature.array();
|
|
},
|
|
});
|
|
type Output = z.output<typeof Output>;
|
|
|
|
type _Feature = {
|
|
title: string;
|
|
features?: _Feature[] | undefined;
|
|
};
|
|
|
|
type _Output = {
|
|
id: number;
|
|
name: string;
|
|
features: _Feature[];
|
|
};
|
|
|
|
// expectTypeOf<Feature>().toEqualTypeOf<_Feature>();
|
|
expectTypeOf<Output>().toEqualTypeOf<_Output>();
|
|
});
|
|
|
|
test("deferred mutual recursion", () => {
|
|
const Slot = z.object({
|
|
slotCode: z.string(),
|
|
|
|
get blocks() {
|
|
return z.array(Block);
|
|
},
|
|
});
|
|
type Slot = z.infer<typeof Slot>;
|
|
|
|
const Block = z.object({
|
|
blockCode: z.string(),
|
|
get slots() {
|
|
return z.array(Slot).optional();
|
|
},
|
|
});
|
|
type Block = z.infer<typeof Block>;
|
|
|
|
const Page = z.object({
|
|
slots: z.array(Slot),
|
|
});
|
|
type Page = z.infer<typeof Page>;
|
|
|
|
type _Slot = {
|
|
slotCode: string;
|
|
blocks: _Block[];
|
|
};
|
|
type _Block = {
|
|
blockCode: string;
|
|
slots?: _Slot[] | undefined;
|
|
};
|
|
type _Page = {
|
|
slots: _Slot[];
|
|
};
|
|
expectTypeOf<Slot>().toEqualTypeOf<_Slot>();
|
|
expectTypeOf<Block>().toEqualTypeOf<_Block>();
|
|
expectTypeOf<Page>().toEqualTypeOf<_Page>();
|
|
});
|
|
|
|
test("mutual recursion with meta", () => {
|
|
const A = z
|
|
.object({
|
|
name: z.string(),
|
|
get b() {
|
|
return B;
|
|
},
|
|
})
|
|
.readonly()
|
|
.meta({ id: "A" })
|
|
.optional();
|
|
|
|
const B = z
|
|
.object({
|
|
name: z.string(),
|
|
get a() {
|
|
return A;
|
|
},
|
|
})
|
|
.readonly()
|
|
.meta({ id: "B" });
|
|
|
|
type A = z.infer<typeof A>;
|
|
type B = z.infer<typeof B>;
|
|
|
|
type _A =
|
|
| Readonly<{
|
|
name: string;
|
|
b: _B;
|
|
}>
|
|
| undefined;
|
|
// | undefined;
|
|
type _B = Readonly<{
|
|
name: string;
|
|
a?: _A;
|
|
}>;
|
|
expectTypeOf<A>().toEqualTypeOf<_A>();
|
|
expectTypeOf<B>().toEqualTypeOf<_B>();
|
|
});
|
|
|
|
test("intersection with recursive types", () => {
|
|
const A = z.discriminatedUnion("type", [
|
|
z.object({
|
|
type: z.literal("CONTAINER"),
|
|
}),
|
|
z.object({
|
|
type: z.literal("SCREEN"),
|
|
config: z.object({ x: z.number(), y: z.number() }),
|
|
}),
|
|
]);
|
|
// type A = z.infer<typeof A>;
|
|
|
|
const B = z.object({
|
|
get children() {
|
|
return z.array(C).optional();
|
|
},
|
|
});
|
|
// type B = z.infer<typeof B>;
|
|
|
|
const C = z.intersection(A, B);
|
|
type C = z.infer<typeof C>;
|
|
|
|
type _C = (
|
|
| {
|
|
type: "CONTAINER";
|
|
}
|
|
| {
|
|
type: "SCREEN";
|
|
config: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
}
|
|
) & {
|
|
children?: _C[] | undefined;
|
|
};
|
|
expectTypeOf<C>().toEqualTypeOf<_C>();
|
|
});
|
|
|
|
test("object utilities with recursive types", () => {
|
|
const NodeBase = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
get children() {
|
|
return z.array(Node).optional();
|
|
},
|
|
});
|
|
|
|
// Test extend with new keys (extend throws when overwriting existing keys)
|
|
const NodeOne = NodeBase.extend({
|
|
name: z.literal("nodeOne"),
|
|
get children() {
|
|
return z.array(Node);
|
|
},
|
|
});
|
|
|
|
const NodeTwo = NodeBase.extend({
|
|
name: z.literal("nodeTwo"),
|
|
get children() {
|
|
return z.array(Node);
|
|
},
|
|
});
|
|
|
|
// Test pick
|
|
const PickedNode = NodeBase.pick({ id: true, name: true });
|
|
|
|
// Test omit
|
|
const OmittedNode = NodeBase.omit({ children: true });
|
|
|
|
// Test merge
|
|
const ExtraProps = {
|
|
metadata: z.string(),
|
|
get parent() {
|
|
return Node.optional();
|
|
},
|
|
};
|
|
const MergedNode = NodeBase.extend(ExtraProps);
|
|
|
|
// Test partial
|
|
const PartialNode = NodeBase.partial();
|
|
const PartialMaskedNode = NodeBase.partial({ name: true });
|
|
|
|
// Test required (assuming NodeBase has optional fields)
|
|
const OptionalNodeBase = z.object({
|
|
id: z.string().optional(),
|
|
name: z.string().optional(),
|
|
get children() {
|
|
return z.array(Node).optional();
|
|
},
|
|
});
|
|
const RequiredNode = OptionalNodeBase.required();
|
|
const RequiredMaskedNode = OptionalNodeBase.required({ id: true });
|
|
|
|
const Node = z.union([
|
|
NodeOne,
|
|
NodeTwo,
|
|
PickedNode,
|
|
OmittedNode,
|
|
MergedNode,
|
|
PartialNode,
|
|
PartialMaskedNode,
|
|
RequiredNode,
|
|
RequiredMaskedNode,
|
|
]);
|
|
});
|
|
|
|
test("tuple with recursive types", () => {
|
|
const TaskListNodeSchema = z.strictObject({
|
|
type: z.literal("taskList"),
|
|
get content() {
|
|
return z.array(z.tuple([TaskListNodeSchema, z.union([TaskListNodeSchema])])).min(1);
|
|
},
|
|
});
|
|
type TaskListNodeSchema = z.infer<typeof TaskListNodeSchema>;
|
|
type _TaskListNodeSchema = {
|
|
type: "taskList";
|
|
content: [_TaskListNodeSchema, _TaskListNodeSchema][];
|
|
};
|
|
expectTypeOf<TaskListNodeSchema>().toEqualTypeOf<_TaskListNodeSchema>();
|
|
});
|
|
|
|
test("recursion compatibility", () => {
|
|
// array
|
|
const A = z.object({
|
|
get array() {
|
|
return A.array();
|
|
},
|
|
get optional() {
|
|
return A.optional();
|
|
},
|
|
get nullable() {
|
|
return A.nullable();
|
|
},
|
|
get nonoptional() {
|
|
return A.nonoptional();
|
|
},
|
|
get readonly() {
|
|
return A.readonly();
|
|
},
|
|
get describe() {
|
|
return A.describe("A recursive type");
|
|
},
|
|
get meta() {
|
|
return A.meta({ description: "A recursive type" });
|
|
},
|
|
get pipe() {
|
|
return A.pipe(z.any());
|
|
},
|
|
get strict() {
|
|
return A.strict();
|
|
},
|
|
get tuple() {
|
|
return z.tuple([A, A]);
|
|
},
|
|
get object() {
|
|
return z
|
|
.object({
|
|
subcategories: A,
|
|
})
|
|
.strict()
|
|
.loose();
|
|
},
|
|
get union() {
|
|
return z.union([A, A]);
|
|
},
|
|
get intersection() {
|
|
return z.intersection(A, A);
|
|
},
|
|
get record() {
|
|
return z.record(z.string(), A);
|
|
},
|
|
get map() {
|
|
return z.map(z.string(), A);
|
|
},
|
|
get set() {
|
|
return z.set(A);
|
|
},
|
|
get lazy() {
|
|
return z.lazy(() => A);
|
|
},
|
|
get promise() {
|
|
return z.promise(A);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("recursive object with .check()", () => {
|
|
const Category = z
|
|
.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
get subcategories() {
|
|
return z.array(Category).optional();
|
|
},
|
|
})
|
|
.check((ctx) => {
|
|
// Check for duplicate IDs among direct subcategories
|
|
if (ctx.value.subcategories) {
|
|
const siblingIds = new Set<string>();
|
|
ctx.value.subcategories.forEach((sub, index) => {
|
|
if (siblingIds.has(sub.id)) {
|
|
ctx.issues.push({
|
|
code: "custom",
|
|
message: `Duplicate sibling ID found: ${sub.id}`,
|
|
path: ["subcategories", index, "id"],
|
|
input: ctx.value,
|
|
});
|
|
}
|
|
siblingIds.add(sub.id);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Valid - siblings have unique IDs
|
|
const validData = {
|
|
id: "electronics",
|
|
name: "Electronics",
|
|
subcategories: [
|
|
{
|
|
id: "computers",
|
|
name: "Computers",
|
|
subcategories: [
|
|
{ id: "laptops", name: "Laptops" },
|
|
{ id: "desktops", name: "Desktops" },
|
|
],
|
|
},
|
|
{
|
|
id: "phones",
|
|
name: "Phones",
|
|
},
|
|
],
|
|
};
|
|
|
|
// Invalid - duplicate sibling IDs
|
|
const invalidData = {
|
|
id: "electronics",
|
|
name: "Electronics",
|
|
subcategories: [
|
|
{ id: "computers", name: "Computers" },
|
|
{ id: "phones", name: "Phones" },
|
|
{ id: "computers", name: "Computers Again" }, // Duplicate at index 2
|
|
],
|
|
};
|
|
|
|
expect(() => Category.parse(validData)).not.toThrow();
|
|
expect(() => Category.parse(invalidData)).toThrow();
|
|
});
|
|
|
|
// biome-ignore lint: sadf
|
|
export type RecursiveA = z.ZodUnion<
|
|
[
|
|
z.ZodObject<{
|
|
a: z.ZodDefault<RecursiveA>;
|
|
b: z.ZodPrefault<RecursiveA>;
|
|
c: z.ZodNonOptional<RecursiveA>;
|
|
d: z.ZodOptional<RecursiveA>;
|
|
e: z.ZodNullable<RecursiveA>;
|
|
g: z.ZodReadonly<RecursiveA>;
|
|
h: z.ZodPipe<RecursiveA, z.ZodString>;
|
|
i: z.ZodArray<RecursiveA>;
|
|
j: z.ZodSet<RecursiveA>;
|
|
k: z.ZodMap<RecursiveA, RecursiveA>;
|
|
l: z.ZodRecord<z.ZodString, RecursiveA>;
|
|
m: z.ZodUnion<[RecursiveA, RecursiveA]>;
|
|
n: z.ZodIntersection<RecursiveA, RecursiveA>;
|
|
o: z.ZodLazy<RecursiveA>;
|
|
p: z.ZodPromise<RecursiveA>;
|
|
q: z.ZodCatch<RecursiveA>;
|
|
r: z.ZodSuccess<RecursiveA>;
|
|
s: z.ZodTransform<RecursiveA, string>;
|
|
t: z.ZodTuple<[RecursiveA, RecursiveA]>;
|
|
u: z.ZodObject<{
|
|
a: RecursiveA;
|
|
}>;
|
|
}>,
|
|
]
|
|
>;
|
|
|
|
test("recursive type with `id` meta", () => {
|
|
const AType = z.object({
|
|
type: z.literal("a"),
|
|
name: z.string(),
|
|
});
|
|
|
|
const BType = z.object({
|
|
type: z.literal("b"),
|
|
name: z.string(),
|
|
});
|
|
|
|
const CType = z.object({
|
|
type: z.literal("c"),
|
|
name: z.string(),
|
|
});
|
|
|
|
const Schema = z.object({
|
|
type: z.literal("special").meta({ description: "Type" }),
|
|
config: z.object({
|
|
title: z.string().meta({ description: "Title" }),
|
|
get elements() {
|
|
return z.array(z.discriminatedUnion("type", [AType, BType, CType])).meta({
|
|
id: "SpecialElements",
|
|
title: "SpecialElements",
|
|
description: "Array of elements",
|
|
});
|
|
},
|
|
}),
|
|
});
|
|
|
|
Schema.parse({
|
|
type: "special",
|
|
config: {
|
|
title: "Special",
|
|
elements: [
|
|
{ type: "a", name: "John" },
|
|
{ type: "b", name: "Jane" },
|
|
{ type: "c", name: "Jim" },
|
|
],
|
|
},
|
|
});
|
|
});
|