UNPKG

zod

Version:

TypeScript-first schema declaration and validation library with static type inference

497 lines (436 loc) 17.9 kB
import { expect, expectTypeOf, test } from "vitest"; import * as z from "zod/v4"; test("successful validation", () => { const testTuple = z.tuple([z.string(), z.number()]); expectTypeOf<typeof testTuple._output>().toEqualTypeOf<[string, number]>(); const val = testTuple.parse(["asdf", 1234]); expect(val).toEqual(val); const r1 = testTuple.safeParse(["asdf", "asdf"]); expect(r1.success).toEqual(false); expect(r1.error!).toMatchInlineSnapshot(` [ZodError: [ { "expected": "number", "code": "invalid_type", "path": [ 1 ], "message": "Invalid input: expected number, received string" } ]] `); const r2 = testTuple.safeParse(["asdf", 1234, true]); expect(r2.success).toEqual(false); expect(r2.error!).toMatchInlineSnapshot(` [ZodError: [ { "code": "too_big", "maximum": 2, "inclusive": true, "origin": "array", "path": [], "message": "Too big: expected array to have <=2 items" } ]] `); const r3 = testTuple.safeParse({}); expect(r3.success).toEqual(false); expect(r3.error!).toMatchInlineSnapshot(` [ZodError: [ { "expected": "tuple", "code": "invalid_type", "path": [], "message": "Invalid input: expected tuple, received object" } ]] `); }); test("async validation", async () => { const testTuple = z .tuple([z.string().refine(async () => true), z.number().refine(async () => true)]) .refine(async () => true); expectTypeOf<typeof testTuple._output>().toEqualTypeOf<[string, number]>(); const val = await testTuple.parseAsync(["asdf", 1234]); expect(val).toEqual(val); const r1 = await testTuple.safeParseAsync(["asdf", "asdf"]); expect(r1.success).toEqual(false); expect(r1.error!).toMatchInlineSnapshot(` [ZodError: [ { "expected": "number", "code": "invalid_type", "path": [ 1 ], "message": "Invalid input: expected number, received string" } ]] `); const r2 = await testTuple.safeParseAsync(["asdf", 1234, true]); expect(r2.success).toEqual(false); expect(r2.error!).toMatchInlineSnapshot(` [ZodError: [ { "code": "too_big", "maximum": 2, "inclusive": true, "origin": "array", "path": [], "message": "Too big: expected array to have <=2 items" } ]] `); const r3 = await testTuple.safeParseAsync({}); expect(r3.success).toEqual(false); expect(r3.error!).toMatchInlineSnapshot(` [ZodError: [ { "expected": "tuple", "code": "invalid_type", "path": [], "message": "Invalid input: expected tuple, received object" } ]] `); }); test("tuple with optional elements", () => { const myTuple = z.tuple([z.string(), z.number().optional(), z.string().optional()]).rest(z.boolean()); expectTypeOf<typeof myTuple._output>().toEqualTypeOf< [string, (number | undefined)?, (string | undefined)?, ...boolean[]] >(); const goodData = [["asdf"], ["asdf", 1234], ["asdf", 1234, "asdf"], ["asdf", 1234, "asdf", true, false, true]]; for (const data of goodData) { expect(myTuple.parse(data)).toEqual(data); } const badData = [ ["asdf", "asdf"], ["asdf", 1234, "asdf", "asdf"], ["asdf", 1234, "asdf", true, false, "asdf"], ]; for (const data of badData) { expect(() => myTuple.parse(data)).toThrow(); } }); test("tuple with optional elements followed by required", () => { const myTuple = z.tuple([z.string(), z.number().optional(), z.string()]).rest(z.boolean()); expectTypeOf<typeof myTuple._output>().toEqualTypeOf<[string, number | undefined, string, ...boolean[]]>(); const goodData = [ ["asdf", 1234, "asdf"], ["asdf", 1234, "asdf", true, false, true], ]; for (const data of goodData) { expect(myTuple.parse(data)).toEqual(data); } const badData = [ ["asdf"], ["asdf", 1234], ["asdf", 1234, "asdf", "asdf"], ["asdf", 1234, "asdf", true, false, "asdf"], ]; for (const data of badData) { expect(() => myTuple.parse(data)).toThrow(); } }); test("tuple with all optional elements", () => { const allOptionalTuple = z.tuple([z.string().optional(), z.number().optional(), z.boolean().optional()]); expectTypeOf<typeof allOptionalTuple._output>().toEqualTypeOf< [(string | undefined)?, (number | undefined)?, (boolean | undefined)?] >(); // Empty array should be valid (all items optional) expect(allOptionalTuple.parse([])).toEqual([]); // Partial arrays should be valid expect(allOptionalTuple.parse(["hello"])).toEqual(["hello"]); expect(allOptionalTuple.parse(["hello", 42])).toEqual(["hello", 42]); // Full array should be valid expect(allOptionalTuple.parse(["hello", 42, true])).toEqual(["hello", 42, true]); // Array that's too long should fail expect(() => allOptionalTuple.parse(["hello", 42, true, "extra"])).toThrow(); }); test("tuple fills defaults for missing trailing elements", () => { // Issue #5229: trailing `.default()`/`.prefault()` elements should be // filled in when the input array is shorter than the tuple. const t = z.tuple([z.string(), z.string().default("bravo")]); expectTypeOf<typeof t._output>().toEqualTypeOf<[string, string]>(); expectTypeOf<typeof t._input>().toEqualTypeOf<[string, (string | undefined)?]>(); expect(t.parse(["alpha", "charlie"])).toEqual(["alpha", "charlie"]); expect(t.parse(["alpha"])).toEqual(["alpha", "bravo"]); // Multiple trailing defaults const multi = z.tuple([z.string(), z.number().default(42), z.boolean().default(true)]); expect(multi.parse(["hello"])).toEqual(["hello", 42, true]); expect(multi.parse(["hello", 100])).toEqual(["hello", 100, true]); expect(multi.parse(["hello", 100, false])).toEqual(["hello", 100, false]); // Prefault parity expect(z.tuple([z.string(), z.string().prefault("delta")]).parse(["alpha"])).toEqual(["alpha", "delta"]); // Defaults wrapped in modifiers: `optout` propagates through these, so the // fix is not type-name specific. expect(z.tuple([z.string(), z.string().default("x").nullable()]).parse(["alpha"])).toEqual(["alpha", "x"]); expect(z.tuple([z.string(), z.string().default("x").readonly()]).parse(["alpha"])).toEqual(["alpha", "x"]); expect(z.tuple([z.string(), z.string().default("x").catch("y")]).parse(["alpha"])).toEqual(["alpha", "x"]); expect(z.tuple([z.string(), z.string().default("x").pipe(z.string())]).parse(["alpha"])).toEqual(["alpha", "x"]); }); test("tuple fills defaults under async parse", async () => { const t = z.tuple([z.string(), z.string().default("zulu")]); await expect(t.parseAsync(["alpha"])).resolves.toEqual(["alpha", "zulu"]); }); test("tuple keeps length-1 array for missing `.optional()` elements", () => { // Backwards compat: a trailing `.optional()` element that is omitted from // the input must NOT be filled with `undefined` — the result stays length-1. // Only schemas that produce a defined value get materialized. const t = z.tuple([z.string(), z.string().optional()]); const out = t.parse(["alpha"]); expect(out).toEqual(["alpha"]); expect(out.length).toEqual(1); // `z.undefined()` is NOT a synonym for `.optional()` — its value type is // *must be undefined*, so the slot is required input. Omitting it triggers // a single `too_small` (no element-level errors, matching v3's abort // semantics); passing explicit `undefined` succeeds and is preserved. expect(z.tuple([z.string(), z.undefined()]).safeParse(["alpha"]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=2 items", "minimum": 2, "origin": "array", "path": [], }, ] `); expect(z.tuple([z.string(), z.undefined()]).parse(["alpha", undefined])).toHaveLength(2); // `.optional().nullable()` still trims — `.optional()` propagates the // optin/optout flags through the nullable wrapper. expect(z.tuple([z.string(), z.string().optional().nullable()]).parse(["alpha"])).toHaveLength(1); // Multiple trailing optionals trim the same way — we don't fill the tail // with literal `undefined`s. const many = z.tuple([z.string(), z.string().optional(), z.string().optional(), z.string().optional()]); expect(many.parse(["alpha"])).toEqual(["alpha"]); expect(many.parse(["alpha", "beta"])).toEqual(["alpha", "beta"]); // Explicit `undefined` inside `input.length` IS preserved — only slots // past the input get trimmed. const r = many.parse(["alpha", undefined]); expect(r.length).toEqual(2); expect(1 in r).toEqual(true); // Trailing optionals after a default that fires are still trimmed. expect( z.tuple([z.string(), z.string().default("d"), z.string().optional(), z.string().optional()]).parse(["alpha"]) ).toEqual(["alpha", "d"]); }); test("tuple result is dense when optional precedes a default", () => { // `.optional()` before a `.default()` must produce an explicit `undefined` // (not a sparse hole), otherwise `1 in r`, `JSON.stringify`, `Object.keys`, // and iteration all behave wrong. const t = z.tuple([z.string(), z.string().optional(), z.string().default("z")]); const r = t.parse(["alpha"]); expect(r).toEqual(["alpha", undefined, "z"]); expect(r.length).toEqual(3); expect(1 in r).toEqual(true); expect(JSON.stringify(r)).toEqual('["alpha",null,"z"]'); // Trailing optional after a default is still dropped (no later default // forces it to materialize). expect(z.tuple([z.string(), z.string().default("d"), z.string().optional()]).parse(["alpha"])).toEqual([ "alpha", "d", ]); // Multiple interleaved optional/default — every slot up to the last // default must be present and dense. const interleaved = z.tuple([ z.string(), z.string().optional(), z.string().default("d"), z.string().optional(), z.string().default("e"), ]); const out = interleaved.parse(["alpha"]); expect(out).toEqual(["alpha", undefined, "d", undefined, "e"]); expect(1 in out && 3 in out).toEqual(true); }); test("tuple truncates absent optional rejections only when the output tail is optional", () => { // An absent optional-output slot can only be swallowed when every later // output slot is optional too. If a later default would make the output tail // required, truncating would violate the tuple's output type. const refusesUndefined = z .string() .optional() .refine((s) => s !== undefined, "must not be undefined"); const trailingDefault = z.tuple([z.string(), refusesUndefined, z.string().default("d")]); const r1 = trailingDefault.safeParse(["alpha"]); expect(r1.success).toBe(false); expect(r1.error!.issues[0].path).toEqual([1]); // Optional slots BEFORE the rejected one still cannot hide a later required // output slot. const beforeReject = z.tuple([z.string(), z.string().optional(), refusesUndefined, z.string().default("d")]); const r2 = beforeReject.safeParse(["alpha"]); expect(r2.success).toBe(false); expect(r2.error!.issues[0].path).toEqual([2]); // No default after — truncate still applies, no spurious issue surfaces. const noTrailingDefault = z.tuple([z.string(), refusesUndefined]); const r3 = noTrailingDefault.safeParse(["alpha"]); expect(r3.success).toBe(true); expect(r3.data).toEqual(["alpha"]); }); test("tuple rejects absent optional before required output under async parse", async () => { const refusesUndefined = z .string() .optional() .refine(async (s) => s !== undefined, "must not be undefined"); const schema = z.tuple([z.string(), refusesUndefined, z.string().default("d")]); const r = await schema.safeParseAsync(["alpha"]); expect(r.success).toBe(false); expect(r.error!.issues[0].path).toEqual([1]); }); test("tuple rejects absent exact optional before defaulted output", () => { const schema = z.tuple([z.string(), z.string().exactOptional(), z.string().default("fallback")]); expectTypeOf<typeof schema._output>().toEqualTypeOf<[string, string, string]>(); const missingExact = schema.safeParse(["alpha"]); expect(missingExact.success).toBe(false); expect(missingExact.error!.issues[0].path).toEqual([1]); expect(schema.parse(["alpha", "bravo"])).toEqual(["alpha", "bravo", "fallback"]); expect(schema.safeParse(["alpha", undefined]).success).toBe(false); // With no later required output slot, exact optional still behaves like an // omitted tuple tail and truncates cleanly. expect(z.tuple([z.string(), z.string().exactOptional(), z.string().optional()]).parse(["alpha"])).toEqual(["alpha"]); }); test("tuple preserves explicit undefined inside input even for optional-out schemas", () => { // The trim only runs for slots PAST `input.length`. An explicit `undefined` // value supplied by the caller at index < input.length must survive, even // when the schema produces undefined as a valid output (e.g. // `z.string().or(z.undefined())`, `z.string().optional()`, `z.undefined()`). const orUndefined = z.tuple([z.string(), z.string().or(z.undefined())]); const r1 = orUndefined.parse(["alpha", undefined]); expect(r1.length).toEqual(2); expect(r1[1]).toBeUndefined(); expect(1 in r1).toEqual(true); expect(JSON.stringify(r1)).toEqual('["alpha",null]'); // Same for `.optional()`. const opt = z.tuple([z.string(), z.string().optional()]); const r2 = opt.parse(["alpha", undefined]); expect(r2.length).toEqual(2); expect(1 in r2).toEqual(true); // Same for `z.undefined()` literal. const lit = z.tuple([z.string(), z.undefined()]); const r3 = lit.parse(["alpha", undefined]); expect(r3.length).toEqual(2); expect(1 in r3).toEqual(true); // Mid-tuple explicit undefined surrounded by defined values is also kept. const mid = z.tuple([z.string(), z.string().or(z.undefined()), z.string()]); const r4 = mid.parse(["alpha", undefined, "gamma"]); expect(r4).toEqual(["alpha", undefined, "gamma"]); expect(r4.length).toEqual(3); expect(1 in r4).toEqual(true); }); test("tuple does NOT break when a required slot fails past input length", () => { // A required slot (no `.optional()` chain, so optout !== "optional") past // input length must still surface an issue rather than silently swallowing // it. Otherwise we'd accept arbitrarily short tuples for required-tail // schemas. The precheck collapses this into a single `too_small`. const schema = z.tuple([z.string(), z.string()]); expect(schema.safeParse(["alpha"]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=2 items", "minimum": 2, "origin": "array", "path": [], }, ] `); }); test("tuple with rest schema", () => { const myTuple = z.tuple([z.string(), z.number()]).rest(z.boolean()); expect(myTuple.parse(["asdf", 1234, true, false, true])).toEqual(["asdf", 1234, true, false, true]); expect(myTuple.parse(["asdf", 1234])).toEqual(["asdf", 1234]); expect(() => myTuple.parse(["asdf", 1234, "asdf"])).toThrow(); type t1 = z.output<typeof myTuple>; expectTypeOf<t1>().toEqualTypeOf<[string, number, ...boolean[]]>(); }); test("sparse array input", () => { const schema = z.tuple([z.string(), z.number()]); expect(() => schema.parse(new Array(2))).toThrow(); }); test("under-length tuple emits a single too_small with optStart minimum", () => { const allRequired = z.tuple([z.string(), z.string()]); expect(allRequired.safeParse(["a"]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=2 items", "minimum": 2, "origin": "array", "path": [], }, ] `); expect(allRequired.safeParse([]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=2 items", "minimum": 2, "origin": "array", "path": [], }, ] `); const trailingOptional = z.tuple([z.string(), z.number().optional()]); expect(trailingOptional.safeParse([]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=1 items", "minimum": 1, "origin": "array", "path": [], }, ] `); const interiorOptional = z.tuple([z.string(), z.number().optional(), z.string()]); expect(interiorOptional.safeParse(["a", 1]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Too small: expected array to have >=3 items", "minimum": 3, "origin": "array", "path": [], }, ] `); }); test("too_big tuple still surfaces element-wise type errors for present indices", () => { const schema = z.tuple([z.string(), z.number()]); expect(schema.safeParse([1, "x", "extra"]).error!.issues).toMatchInlineSnapshot(` [ { "code": "too_big", "inclusive": true, "maximum": 2, "message": "Too big: expected array to have <=2 items", "origin": "array", "path": [], }, { "code": "invalid_type", "expected": "string", "message": "Invalid input: expected string, received number", "path": [ 0, ], }, { "code": "invalid_type", "expected": "number", "message": "Invalid input: expected number, received string", "path": [ 1, ], }, ] `); });