zod
Version:
TypeScript-first schema declaration and validation library with static type inference
142 lines (119 loc) • 6.68 kB
text/typescript
import { expect, expectTypeOf, test } from "vitest";
import * as z from "zod/v4";
test("z.properties is a check", () => {
const s = z.object({ a: z.string(), b: z.string() }).check(z.properties({ a: z.literal("x"), b: z.literal("y") }));
// asserts in place: extra keys pass, and every failing key reports
expect(s.safeParse({ a: "x", b: "y", extra: 1 }).success).toBe(true);
expect(s.safeParse({ a: "!", b: "!" }).error!.issues.map((i) => i.path)).toEqual([["a"], ["b"]]);
// the base already typed the value, so the properties are asserted on whatever it produced — a string's length, as z.property() does
const long = z.string().check(z.properties({ length: z.number().min(3) }));
expect(long.parse("abc")).toBe("abc");
expect(long.safeParse("ab").error!.issues.map((i) => i.path)).toEqual([["length"]]);
expect(
z
.compile(long)
.safeParse("ab")
.error!.issues.map((i) => i.path)
).toEqual([["length"]]);
// not a schema
// @ts-expect-error a check has no parse of its own
z.properties({ a: z.string() }).parse;
});
test("z.properties discards transformed output", () => {
// asserts and never writes back, exactly as z.property() behaves; a nested object schema rebuilds its output even without transforms, so identity cannot distinguish the two
const s = z.any().check(z.properties({ a: z.string().transform((s) => s.toUpperCase()) }));
const input = { a: "hi" };
expect(s.parse(input)).toBe(input);
expect(input.a).toBe("hi");
});
test("z.instanceof().properties()", () => {
const httpsUrl = z.instanceof(URL).properties({ protocol: z.literal("https:") });
expectTypeOf<z.infer<typeof httpsUrl>>().toEqualTypeOf<URL & { protocol: "https:" }>();
const u = new URL("https://example.com");
expect(httpsUrl.parse(u)).toBe(u);
expect(httpsUrl.safeParse(new URL("http://example.com")).error!.issues.map((i) => i.path)).toEqual([["protocol"]]);
// chains, and each call narrows further
const chained = httpsUrl.properties({ port: z.literal("") });
expectTypeOf<z.infer<typeof chained>>().toEqualTypeOf<URL & { protocol: "https:" } & { port: "" }>();
expect(chained.safeParse(u).success).toBe(true);
expect(chained.safeParse(new URL("https://example.com:8443")).error!.issues.map((i) => i.path)).toEqual([["port"]]);
// nothing is written back, so the narrowing is over the shape's input side
const W = z.instanceof(URL).properties({ search: z.string().transform((s) => s.length) });
expectTypeOf<z.infer<typeof W>>().toEqualTypeOf<URL & { search: string }>();
});
test("z.instanceof().properties() constrains the shape to the instance type", () => {
const file = z.instanceof(File).properties({ name: z.string(), size: z.number().max(1024) });
expectTypeOf<z.infer<typeof file>>().toEqualTypeOf<File & { name: string; size: number }>();
// @ts-expect-error no such property on File
z.instanceof(File).properties({ nmae: z.string() });
// @ts-expect-error File["size"] is a number
z.instanceof(File).properties({ size: z.string() });
// methods are out of the shape, so an object literal's inherited toString can't collide with the constraint
// @ts-expect-error
z.instanceof(File).properties({ text: z.any() });
// a literal still narrows a wider property
const png = z.instanceof(File).properties({ type: z.literal("image/png") });
expectTypeOf<z.infer<typeof png>>().toEqualTypeOf<File & { type: "image/png" }>();
});
test("z.properties spreads into .check()", () => {
// the 4.5 array call sites: the check yields itself from Symbol.iterator
const p = z.properties({ a: z.literal("x") });
expect([...p]).toEqual([p]);
const s = z.object({ a: z.string() }).check(...z.properties({ a: z.literal("x") }));
expect(s.safeParse({ a: "x" }).success).toBe(true);
expect(s.safeParse({ a: "!" }).error!.issues.map((i) => i.path)).toEqual([["a"]]);
});
test("z.properties compiles inside a container", () => {
const outer = z.compile(z.object({ url: z.instanceof(URL).properties({ protocol: z.literal("https:") }) }));
expect(outer.safeParse({ url: new URL("http://example.com") }).error!.issues.map((i) => i.path)).toEqual([
["url", "protocol"],
]);
});
test("z.properties async", async () => {
const s = z.any().check(z.properties({ a: z.string().refine(async (s) => s.length > 1) }));
const ok = { a: "hi" };
await expect(s.parseAsync(ok)).resolves.toBe(ok);
const bad = await s.safeParseAsync({ a: "!" });
expect(bad.error!.issues.map((i) => i.path)).toEqual([["a"]]);
});
test("z.properties validates symbol keys", () => {
const sym = Symbol("tag");
const s = z.any().check(z.properties({ [sym]: z.string() }));
expect(s.safeParse({ [sym]: "ok" }).success).toBe(true);
expect(s.safeParse({ [sym]: 123 }).error!.issues.map((i) => i.path)).toEqual([[sym]]);
expect(
z
.compile(s)
.safeParse({ [sym]: 123 })
.error!.issues.map((i) => i.path)
).toEqual([[sym]]);
});
test("z.properties compiled and interpreted agree on a nullish value", () => {
// a base that permits null reaches the check with one; the compiled property read must not throw where the runtime reports an issue
const s = z.any().check(...z.properties({ a: z.string() }));
const expected = [["invalid_type", []]];
expect(s.safeParse(null).error!.issues.map((i) => [i.code, i.path])).toEqual(expected);
expect(
z
.compile(s)
.safeParse(null)
.error!.issues.map((i) => [i.code, i.path])
).toEqual(expected);
});
test("z.properties with a custom when refuses to compile", () => {
// `when` is not publicly settable, since it gates the assertion inside the run loop. A hand-built def can still carry one, and compiling it must refuse rather than run the shape unconditionally: a union branch compiles to its own IIFE, so a wrong rejection is absorbed as a branch failure with no fallback.
const p = new z.core.$ZodCheckProperties({ check: "properties", shape: { a: z.literal("x") }, when: () => false });
const s = z.union([z.any().check(p), z.object({ b: z.string() })]);
const input = { a: "wrong", b: "hello" };
expect(z.compile(s).safeParse(input)).toEqual(s.safeParse(input));
});
test("z.properties survives shape mutation", () => {
// key and schema are snapshotted together; caching one and reading the other live paired a stale key with a missing schema
const shape: Record<string, z.ZodType> = { a: z.string() };
const s = z.any().check(z.properties(shape));
expect(s.safeParse({ a: "x" }).success).toBe(true);
delete shape.a;
expect(s.safeParse({ a: "x" }).success).toBe(true);
shape.b = z.string();
expect(s.safeParse({ a: "x" }).success).toBe(true);
});