|
| 1 | +import { describe, expect, expectTypeOf, test } from "vitest"; |
| 2 | +import * as z from "zod"; |
| 3 | +import * as core from "../../core/index.js"; |
| 4 | +import { deepPartial } from "../deep-partial.js"; |
| 5 | + |
| 6 | +const { mapOnSchema } = core; |
| 7 | + |
| 8 | +describe("deepPartial", () => { |
| 9 | + test("makes shallow object optional", () => { |
| 10 | + const schema = z.object({ a: z.string(), b: z.number() }); |
| 11 | + const partial = deepPartial(schema); |
| 12 | + expect(partial.parse({})).toEqual({}); |
| 13 | + expect(partial.parse({ a: "x" })).toEqual({ a: "x" }); |
| 14 | + expect(partial.parse({ a: "x", b: 1 })).toEqual({ a: "x", b: 1 }); |
| 15 | + }); |
| 16 | + |
| 17 | + test("makes nested object optional at every level", () => { |
| 18 | + const schema = z.object({ |
| 19 | + name: z.string(), |
| 20 | + profile: z.object({ |
| 21 | + bio: z.string(), |
| 22 | + age: z.number(), |
| 23 | + }), |
| 24 | + }); |
| 25 | + const partial = deepPartial(schema); |
| 26 | + expect(partial.parse({})).toEqual({}); |
| 27 | + expect(partial.parse({ profile: {} })).toEqual({ profile: {} }); |
| 28 | + expect(partial.parse({ profile: { bio: "x" } })).toEqual({ profile: { bio: "x" } }); |
| 29 | + }); |
| 30 | + |
| 31 | + test("recurses through arrays", () => { |
| 32 | + const schema = z.object({ items: z.array(z.object({ a: z.string(), b: z.number() })) }); |
| 33 | + const partial = deepPartial(schema); |
| 34 | + expect(partial.parse({ items: [{}, { a: "x" }] })).toEqual({ items: [{}, { a: "x" }] }); |
| 35 | + }); |
| 36 | + |
| 37 | + test("recurses through tuples and rest", () => { |
| 38 | + const schema = z.tuple([z.object({ a: z.string() }), z.object({ b: z.number() })], z.object({ c: z.boolean() })); |
| 39 | + const partial = deepPartial(schema); |
| 40 | + expect(partial.parse([{}, {}])).toEqual([{}, {}]); |
| 41 | + expect(partial.parse([{}, {}, {}])).toEqual([{}, {}, {}]); |
| 42 | + }); |
| 43 | + |
| 44 | + test("recurses through unions", () => { |
| 45 | + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]); |
| 46 | + const partial = deepPartial(schema); |
| 47 | + expect(partial.parse({})).toEqual({}); |
| 48 | + }); |
| 49 | + |
| 50 | + test("recurses through optional/nullable wrappers", () => { |
| 51 | + const schema = z.object({ x: z.object({ a: z.string() }).optional() }); |
| 52 | + const partial = deepPartial(schema); |
| 53 | + expect(partial.parse({})).toEqual({}); |
| 54 | + expect(partial.parse({ x: {} })).toEqual({ x: {} }); |
| 55 | + }); |
| 56 | + |
| 57 | + test("terminates on recursive (lazy) schemas", () => { |
| 58 | + const Node: z.ZodType = z.object({ |
| 59 | + name: z.string(), |
| 60 | + children: z.array(z.lazy(() => Node)).optional(), |
| 61 | + }); |
| 62 | + const partial = deepPartial(Node); |
| 63 | + expect(partial.parse({})).toEqual({}); |
| 64 | + expect(partial.parse({ name: "root", children: [{ name: "child" }] })).toEqual({ |
| 65 | + name: "root", |
| 66 | + children: [{ name: "child" }], |
| 67 | + }); |
| 68 | + expect(partial.parse({ children: [{}] })).toEqual({ children: [{}] }); |
| 69 | + }); |
| 70 | + |
| 71 | + test("preserves primitives at leaves", () => { |
| 72 | + const schema = z.object({ a: z.string().min(1), b: z.number().int() }); |
| 73 | + const partial = deepPartial(schema); |
| 74 | + expect(() => partial.parse({ a: "" })).toThrow(); |
| 75 | + expect(() => partial.parse({ b: 1.5 })).toThrow(); |
| 76 | + expect(partial.parse({ a: "ok", b: 1 })).toEqual({ a: "ok", b: 1 }); |
| 77 | + }); |
| 78 | + |
| 79 | + test("keyof preserved on output type", () => { |
| 80 | + const schema = z.object({ |
| 81 | + name: z.string(), |
| 82 | + profile: z.object({ bio: z.string() }), |
| 83 | + }); |
| 84 | + type Out = z.output<typeof schema> extends infer X ? X : never; |
| 85 | + const partial = deepPartial(schema); |
| 86 | + type PartialOut = z.output<typeof partial>; |
| 87 | + expectTypeOf<keyof PartialOut>().toEqualTypeOf<keyof Out>(); |
| 88 | + }); |
| 89 | + |
| 90 | + test("3-level deep nesting", () => { |
| 91 | + const schema = z.object({ |
| 92 | + a: z.object({ |
| 93 | + b: z.object({ |
| 94 | + c: z.object({ d: z.string() }), |
| 95 | + }), |
| 96 | + }), |
| 97 | + }); |
| 98 | + const partial = deepPartial(schema); |
| 99 | + expect(partial.parse({})).toEqual({}); |
| 100 | + expect(partial.parse({ a: {} })).toEqual({ a: {} }); |
| 101 | + expect(partial.parse({ a: { b: { c: {} } } })).toEqual({ a: { b: { c: {} } } }); |
| 102 | + }); |
| 103 | + |
| 104 | + test("discriminated union: degrades to plain union (discriminator becomes optional)", () => { |
| 105 | + const schema = z.discriminatedUnion("kind", [ |
| 106 | + z.object({ kind: z.literal("a"), a: z.string() }), |
| 107 | + z.object({ kind: z.literal("b"), b: z.number() }), |
| 108 | + ]); |
| 109 | + const partial = deepPartial(schema); |
| 110 | + expect(partial.parse({ kind: "a", a: "x" })).toEqual({ kind: "a", a: "x" }); |
| 111 | + expect(partial.parse({ kind: "b", b: 1 })).toEqual({ kind: "b", b: 1 }); |
| 112 | + // With discriminator partial, an empty object matches the first option. |
| 113 | + expect(partial.parse({})).toEqual({}); |
| 114 | + }); |
| 115 | + |
| 116 | + test("intersection", () => { |
| 117 | + const schema = z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })); |
| 118 | + const partial = deepPartial(schema); |
| 119 | + expect(partial.parse({})).toEqual({}); |
| 120 | + expect(partial.parse({ a: "x" })).toEqual({ a: "x" }); |
| 121 | + expect(partial.parse({ b: 1 })).toEqual({ b: 1 }); |
| 122 | + }); |
| 123 | + |
| 124 | + test("record / map / set value schemas are recursed", () => { |
| 125 | + const rec = deepPartial(z.record(z.string(), z.object({ a: z.string() }))); |
| 126 | + expect(rec.parse({ k: {} })).toEqual({ k: {} }); |
| 127 | + |
| 128 | + const m = deepPartial(z.map(z.string(), z.object({ a: z.string() }))); |
| 129 | + expect(m.parse(new Map([["k", {}]]))).toEqual(new Map([["k", {}]])); |
| 130 | + |
| 131 | + const s = deepPartial(z.set(z.object({ a: z.string() }))); |
| 132 | + expect(s.parse(new Set([{}]))).toEqual(new Set([{}])); |
| 133 | + }); |
| 134 | + |
| 135 | + test("wrapper composition (optional + default + readonly)", () => { |
| 136 | + const schema = z.object({ |
| 137 | + x: z |
| 138 | + .object({ a: z.string() }) |
| 139 | + .default({ a: "hi" }) |
| 140 | + .readonly(), |
| 141 | + }); |
| 142 | + const partial = deepPartial(schema); |
| 143 | + expect(partial.parse({})).toEqual({ x: { a: "hi" } }); |
| 144 | + expect(partial.parse({ x: {} })).toEqual({ x: {} }); |
| 145 | + }); |
| 146 | + |
| 147 | + test("transform standalone is left untouched", () => { |
| 148 | + const schema = z.object({ a: z.string().transform((s) => s.length) }); |
| 149 | + const partial = deepPartial(schema); |
| 150 | + expect(partial.parse({ a: "hi" })).toEqual({ a: 2 }); |
| 151 | + expect(partial.parse({})).toEqual({}); |
| 152 | + }); |
| 153 | + |
| 154 | + test("pipe input and output are both recursed", () => { |
| 155 | + const schema = z |
| 156 | + .object({ a: z.string() }) |
| 157 | + .pipe(z.object({ a: z.string() })); |
| 158 | + const partial = deepPartial(schema); |
| 159 | + // Both sides of the pipe should accept missing fields. |
| 160 | + expect(partial.parse({})).toEqual({}); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe("mapOnSchema", () => { |
| 165 | + test("identity rewrite preserves parse semantics", () => { |
| 166 | + const schema = z.object({ a: z.string(), nested: z.object({ b: z.number() }) }); |
| 167 | + const same = mapOnSchema(schema, (s) => s); |
| 168 | + expect(same.parse({ a: "x", nested: { b: 1 } })).toEqual({ a: "x", nested: { b: 1 } }); |
| 169 | + }); |
| 170 | + |
| 171 | + test("can target a specific def.type", () => { |
| 172 | + const schema = z.object({ a: z.string(), b: z.number() }); |
| 173 | + const allStrings = mapOnSchema(schema, (s) => (s._zod.def.type === "number" ? z.string() : s)); |
| 174 | + expect(allStrings.parse({ a: "x", b: "y" } as any)).toEqual({ a: "x", b: "y" }); |
| 175 | + }); |
| 176 | + |
| 177 | + test("visits shared sub-schema only once", () => { |
| 178 | + // schema has 3 unique nodes: schema, inner (referenced twice), inner.shape.a (z.string()). |
| 179 | + const inner = z.object({ a: z.string() }); |
| 180 | + const schema = z.object({ x: inner, y: inner }); |
| 181 | + let calls = 0; |
| 182 | + mapOnSchema(schema, (s) => { |
| 183 | + calls++; |
| 184 | + return s; |
| 185 | + }); |
| 186 | + expect(calls).toBe(3); |
| 187 | + }); |
| 188 | + |
| 189 | + test("unknown def.type falls through unchanged", () => { |
| 190 | + const customSchema: any = z.string(); |
| 191 | + customSchema._zod.def = { ...customSchema._zod.def, type: "myCustomType" }; |
| 192 | + const result = mapOnSchema(customSchema, (s) => s); |
| 193 | + expect(result).toBe(customSchema); |
| 194 | + }); |
| 195 | +}); |
| 196 | + |
0 commit comments