UNPKG

zod

Version:

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

530 lines (469 loc) 18.1 kB
import { expect, expectTypeOf, test } from "vitest"; import { en } from "zod/locales"; import * as z from "zod/mini"; z.config(en()); const isoDateCodec = z.codec( z.iso.datetime(), // Input: ISO string (validates to string) z.date(), // Output: Date object { decode: (isoString) => new Date(isoString), // Forward: ISO string → Date encode: (date) => date.toISOString(), // Backward: Date → ISO string } ); test("instanceof", () => { expect(isoDateCodec instanceof z.ZodMiniCodec).toBe(true); expect(isoDateCodec instanceof z.ZodMiniPipe).toBe(true); expect(isoDateCodec instanceof z.ZodMiniType).toBe(true); expect(isoDateCodec instanceof z.core.$ZodCodec).toBe(true); expect(isoDateCodec instanceof z.core.$ZodPipe).toBe(true); expect(isoDateCodec instanceof z.core.$ZodType).toBe(true); expectTypeOf(isoDateCodec.def).toEqualTypeOf<z.core.$ZodCodecDef<z.ZodMiniISODateTime, z.ZodMiniDate<Date>>>(); }); test("codec basic functionality", () => { // ISO string -> Date codec using z.iso.datetime() for input validation const testIsoString = "2024-01-15T10:30:00.000Z"; const testDate = new Date("2024-01-15T10:30:00.000Z"); // Forward decoding (ISO string -> Date) const decodedResult = z.decode(isoDateCodec, testIsoString); expect(decodedResult).toBeInstanceOf(Date); expect(decodedResult.toISOString()).toMatchInlineSnapshot(`"2024-01-15T10:30:00.000Z"`); // Backward encoding (Date -> ISO string) const encodedResult = z.encode(isoDateCodec, testDate); expect(typeof encodedResult).toBe("string"); expect(encodedResult).toMatchInlineSnapshot(`"2024-01-15T10:30:00.000Z"`); }); test("codec round trip", () => { const isoDateCodec = z.codec(z.iso.datetime(), z.date(), { decode: (isoString) => new Date(isoString), encode: (date) => date.toISOString(), }); const original = "2024-12-25T15:45:30.123Z"; const toDate = z.decode(isoDateCodec, original); const backToString = z.encode(isoDateCodec, toDate); expect(backToString).toMatchInlineSnapshot(`"2024-12-25T15:45:30.123Z"`); expect(toDate).toBeInstanceOf(Date); expect(toDate.getTime()).toMatchInlineSnapshot(`1735141530123`); }); test("codec with refinement", () => { const isoDateCodec = z .codec(z.iso.datetime(), z.date(), { decode: (isoString) => new Date(isoString), encode: (date) => date.toISOString(), }) .check(z.refine((val) => val.getFullYear() === 2024, { error: "Year must be 2024" })); // Valid 2024 date const validDate = z.decode(isoDateCodec, "2024-01-15T10:30:00.000Z"); expect(validDate.getFullYear()).toMatchInlineSnapshot(`2024`); expect(validDate.getTime()).toMatchInlineSnapshot(`1705314600000`); // Invalid year should fail safely const invalidYearResult = z.safeDecode(isoDateCodec, "2023-01-15T10:30:00.000Z"); expect(invalidYearResult.success).toBe(false); if (!invalidYearResult.success) { expect(invalidYearResult.error.issues).toMatchInlineSnapshot(` [ { "code": "custom", "message": "Year must be 2024", "path": [], }, ] `); } }); test("safe codec operations", () => { const isoDateCodec = z.codec(z.iso.datetime(), z.date(), { decode: (isoString) => new Date(isoString), encode: (date) => date.toISOString(), }); // Safe decode with invalid input const safeDecodeResult = z.safeDecode(isoDateCodec, "invalid-date"); expect(safeDecodeResult.success).toBe(false); if (!safeDecodeResult.success) { expect(safeDecodeResult.error.issues).toMatchInlineSnapshot(` [ { "code": "invalid_format", "format": "datetime", "message": "Invalid ISO datetime", "origin": "string", "path": [], "pattern": "/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/", }, ] `); } // Safe decode with valid input const safeDecodeValid = z.safeDecode(isoDateCodec, "2024-01-15T10:30:00.000Z"); expect(safeDecodeValid.success).toBe(true); if (safeDecodeValid.success) { expect(safeDecodeValid.data).toBeInstanceOf(Date); expect(safeDecodeValid.data.getTime()).toMatchInlineSnapshot(`1705314600000`); } // Safe encode with valid input const safeEncodeResult = z.safeEncode(isoDateCodec, new Date("2024-01-01")); expect(safeEncodeResult.success).toBe(true); if (safeEncodeResult.success) { expect(safeEncodeResult.data).toMatchInlineSnapshot(`"2024-01-01T00:00:00.000Z"`); } }); test("codec with different types", () => { // String -> Number codec const stringNumberCodec = z.codec(z.string(), z.number(), { decode: (str) => Number.parseFloat(str), encode: (num) => num.toString(), }); const decodedNumber = z.decode(stringNumberCodec, "42.5"); expect(decodedNumber).toMatchInlineSnapshot(`42.5`); expect(typeof decodedNumber).toBe("number"); const encodedString = z.encode(stringNumberCodec, 42.5); expect(encodedString).toMatchInlineSnapshot(`"42.5"`); expect(typeof encodedString).toBe("string"); }); test("async codec operations", async () => { const isoDateCodec = z.codec(z.iso.datetime(), z.date(), { decode: (isoString) => new Date(isoString), encode: (date) => date.toISOString(), }); // Async decode const decodedResult = await z.decodeAsync(isoDateCodec, "2024-01-15T10:30:00.000Z"); expect(decodedResult).toBeInstanceOf(Date); expect(decodedResult.getTime()).toMatchInlineSnapshot(`1705314600000`); // Async encode const encodedResult = await z.encodeAsync(isoDateCodec, new Date("2024-01-15T10:30:00.000Z")); expect(typeof encodedResult).toBe("string"); expect(encodedResult).toMatchInlineSnapshot(`"2024-01-15T10:30:00.000Z"`); // Safe async operations const safeDecodeResult = await z.safeDecodeAsync(isoDateCodec, "2024-01-15T10:30:00.000Z"); expect(safeDecodeResult.success).toBe(true); if (safeDecodeResult.success) { expect(safeDecodeResult.data.getTime()).toMatchInlineSnapshot(`1705314600000`); } const safeEncodeResult = await z.safeEncodeAsync(isoDateCodec, new Date("2024-01-15T10:30:00.000Z")); expect(safeEncodeResult.success).toBe(true); if (safeEncodeResult.success) { expect(safeEncodeResult.data).toMatchInlineSnapshot(`"2024-01-15T10:30:00.000Z"`); } }); test("codec type inference", () => { const codec = z.codec(z.string(), z.number(), { decode: (str) => Number.parseInt(str), encode: (num) => num.toString(), }); // These should compile without type errors const decoded: number = z.decode(codec, "123"); const encoded: string = z.encode(codec, 123); expect(decoded).toMatchInlineSnapshot(`123`); expect(encoded).toMatchInlineSnapshot(`"123"`); }); test("nested codec with object containing codec property", () => { // Nested schema: object containing a codec as one of its properties, with refinements at all levels const waypointSchema = z .object({ name: z.string().check(z.minLength(1, "Waypoint name required")), difficulty: z.enum(["easy", "medium", "hard"]), coordinate: z .codec( z .string() .check(z.regex(/^-?\d+,-?\d+$/, "Must be 'x,y' format")), // Input: coordinate string z .object({ x: z.number(), y: z.number() }) .check(z.refine((coord) => coord.x >= 0 && coord.y >= 0, { error: "Coordinates must be non-negative" })), // Output: coordinate object { decode: (coordString: string) => { const [x, y] = coordString.split(",").map(Number); return { x, y }; }, encode: (coord: { x: number; y: number }) => `${coord.x},${coord.y}`, } ) .check(z.refine((coord) => coord.x <= 1000 && coord.y <= 1000, { error: "Coordinates must be within bounds" })), }) .check( z.refine((waypoint) => waypoint.difficulty !== "hard" || waypoint.coordinate.x >= 100, { error: "Hard waypoints must be at least 100 units from origin", }) ); // Test data const inputWaypoint = { name: "Summit Point", difficulty: "medium" as const, coordinate: "150,200", }; // Forward decoding (object with string coordinate -> object with coordinate object) const decodedWaypoint = z.decode(waypointSchema, inputWaypoint); expect(decodedWaypoint).toMatchInlineSnapshot(` { "coordinate": { "x": 150, "y": 200, }, "difficulty": "medium", "name": "Summit Point", } `); // Backward encoding (object with coordinate object -> object with string coordinate) const encodedWaypoint = z.encode(waypointSchema, decodedWaypoint); expect(encodedWaypoint).toMatchInlineSnapshot(` { "coordinate": "150,200", "difficulty": "medium", "name": "Summit Point", } `); // Test refinements at all levels // String validation (empty waypoint name) const emptyNameResult = z.safeDecode(waypointSchema, { name: "", difficulty: "easy", coordinate: "10,20", }); expect(emptyNameResult.success).toBe(false); if (!emptyNameResult.success) { expect(emptyNameResult.error.issues).toMatchInlineSnapshot(` [ { "code": "too_small", "inclusive": true, "message": "Waypoint name required", "minimum": 1, "origin": "string", "path": [ "name", ], }, ] `); } // Enum validation (invalid difficulty) const invalidDifficultyResult = z.safeDecode(waypointSchema, { name: "Test Point", difficulty: "impossible" as any, coordinate: "10,20", }); expect(invalidDifficultyResult.success).toBe(false); if (!invalidDifficultyResult.success) { expect(invalidDifficultyResult.error.issues).toMatchInlineSnapshot(` [ { "code": "invalid_value", "message": "Invalid option: expected one of "easy"|"medium"|"hard"", "path": [ "difficulty", ], "values": [ "easy", "medium", "hard", ], }, ] `); } // Codec string format validation (invalid coordinate format) const invalidFormatResult = z.safeDecode(waypointSchema, { name: "Test Point", difficulty: "easy", coordinate: "invalid", }); expect(invalidFormatResult.success).toBe(false); if (!invalidFormatResult.success) { expect(invalidFormatResult.error.issues).toMatchInlineSnapshot(` [ { "code": "invalid_format", "format": "regex", "message": "Must be 'x,y' format", "origin": "string", "path": [ "coordinate", ], "pattern": "/^-?\\d+,-?\\d+$/", }, ] `); } // Codec object refinement (negative coordinates) const negativeCoordResult = z.safeDecode(waypointSchema, { name: "Test Point", difficulty: "easy", coordinate: "-5,10", }); expect(negativeCoordResult.success).toBe(false); if (!negativeCoordResult.success) { expect(negativeCoordResult.error.issues).toMatchInlineSnapshot(` [ { "code": "custom", "message": "Coordinates must be non-negative", "path": [ "coordinate", ], }, ] `); } // Codec-level refinement (coordinates out of bounds) const outOfBoundsResult = z.safeDecode(waypointSchema, { name: "Test Point", difficulty: "easy", coordinate: "1500,2000", }); expect(outOfBoundsResult.success).toBe(false); if (!outOfBoundsResult.success) { expect(outOfBoundsResult.error.issues).toMatchInlineSnapshot(` [ { "code": "custom", "message": "Coordinates must be within bounds", "path": [ "coordinate", ], }, ] `); } // Object-level refinement (hard waypoint too close to origin) const hardWaypointResult = z.safeDecode(waypointSchema, { name: "Expert Point", difficulty: "hard", coordinate: "50,60", // x < 100, but hard waypoints need x >= 100 }); expect(hardWaypointResult.success).toBe(false); if (!hardWaypointResult.success) { expect(hardWaypointResult.error.issues).toMatchInlineSnapshot(` [ { "code": "custom", "message": "Hard waypoints must be at least 100 units from origin", "path": [], }, ] `); } // Round trip test const roundTripResult = z.encode(waypointSchema, z.decode(waypointSchema, inputWaypoint)); expect(roundTripResult).toMatchInlineSnapshot(` { "coordinate": "150,200", "difficulty": "medium", "name": "Summit Point", } `); }); test("mutating refinements", () => { const A = z.codec(z.string(), z.string().check(z.trim()), { decode: (val) => val, encode: (val) => val, }); expect(z.decode(A, " asdf ")).toMatchInlineSnapshot(`"asdf"`); expect(z.encode(A, " asdf ")).toMatchInlineSnapshot(`"asdf"`); }); test("codec type enforcement - correct encode/decode signatures", () => { // Test that codec functions have correct type signatures const stringToNumberCodec = z.codec(z.string(), z.number(), { decode: (value: string) => Number(value), // core.output<A> -> core.input<B> encode: (value: number) => String(value), // core.input<B> -> core.output<A> }); // These should compile without errors - correct types (async support) expectTypeOf<(value: string, payload: z.core.ParsePayload<string>) => z.core.util.MaybeAsync<number>>( stringToNumberCodec.def.transform ).toBeFunction(); expectTypeOf<(value: number, payload: z.core.ParsePayload<number>) => z.core.util.MaybeAsync<string>>( stringToNumberCodec.def.reverseTransform ).toBeFunction(); // Test that decode parameter type is core.output<A> (string) const validDecode = (value: string) => Number(value); expectTypeOf(validDecode).toMatchTypeOf<(value: string) => number>(); // Test that encode parameter type is core.input<B> (number) const validEncode = (value: number) => String(value); expectTypeOf(validEncode).toMatchTypeOf<(value: number) => string>(); z.codec(z.string(), z.number(), { // @ts-expect-error - decode should NOT accept core.input<A> as parameter decode: (value: never, _payload) => Number(value), // Wrong: should be string, not unknown encode: (value: number, _payload) => String(value), }); z.codec(z.string(), z.number(), { decode: (value: string) => Number(value), // @ts-expect-error - encode should NOT accept core.output<B> as parameter encode: (value: never) => String(value), // Wrong: should be number, not unknown }); z.codec(z.string(), z.number(), { // @ts-expect-error - decode return type should be core.input<B> decode: (value: string) => String(value), // Wrong: should return number, not string encode: (value: number) => String(value), }); z.codec(z.string(), z.number(), { decode: (value: string) => Number(value), // @ts-expect-error - encode return type should be core.output<A> encode: (value: number) => Number(value), // Wrong: should return string, not number }); }); test("async codec functionality", async () => { // Test that async encode/decode functions work properly const asyncCodec = z.codec(z.string(), z.number(), { decode: async (str) => { await new Promise((resolve) => setTimeout(resolve, 1)); // Simulate async work return Number.parseFloat(str); }, encode: async (num) => { await new Promise((resolve) => setTimeout(resolve, 1)); // Simulate async work return num.toString(); }, }); // Test async decode/encode const decoded = await z.decodeAsync(asyncCodec, "42.5"); expect(decoded).toBe(42.5); const encoded = await z.encodeAsync(asyncCodec, 42.5); expect(encoded).toBe("42.5"); // Test that both sync and async work const mixedCodec = z.codec(z.string(), z.number(), { decode: async (str) => Number.parseFloat(str), encode: (num) => num.toString(), // sync encode }); const mixedResult = await z.decodeAsync(mixedCodec, "123"); expect(mixedResult).toBe(123); }); test("codec type enforcement - complex types", () => { type User = { id: number; name: string }; type UserInput = { id: string; name: string }; const userCodec = z.codec( z.object({ id: z.string(), name: z.string() }), z.object({ id: z.number(), name: z.string() }), { decode: (input: UserInput) => ({ id: Number(input.id), name: input.name }), encode: (user: User) => ({ id: String(user.id), name: user.name }), } ); // Verify correct types are inferred (async support) expectTypeOf<(input: UserInput, payload: z.core.ParsePayload<UserInput>) => z.core.util.MaybeAsync<User>>( userCodec.def.transform ).toBeFunction(); expectTypeOf<(user: User, payload: z.core.ParsePayload<User>) => z.core.util.MaybeAsync<UserInput>>( userCodec.def.reverseTransform ).toBeFunction(); z.codec( z.object({ id: z.string(), name: z.string(), }), z.object({ id: z.number(), name: z.string() }), { // @ts-expect-error - decode parameter should be UserInput, not User decode: (input: User) => ({ id: Number(input.id), name: input.name }), // Wrong type encode: (user: User) => ({ id: String(user.id), name: user.name }), } ); z.codec( z.object({ id: z.string(), name: z.string(), }), z.object({ id: z.number(), name: z.string() }), { decode: (input: UserInput) => ({ id: Number(input.id), name: input.name }), // @ts-expect-error - encode parameter should be User, not UserInput encode: (user: UserInput) => ({ id: String(user.id), name: user.name }), // Wrong type } ); });