diff --git a/typescript/examples/coffeeShop-zod/package.json b/typescript/examples/coffeeShop-zod/package.json index f4a0c3ed..7cc06f15 100644 --- a/typescript/examples/coffeeShop-zod/package.json +++ b/typescript/examples/coffeeShop-zod/package.json @@ -14,7 +14,7 @@ "dotenv": "^16.3.1", "find-config": "^1.0.0", "typechat": "^0.1.0", - "zod": "^3.22.4" + "zod": "^4.0.0" }, "devDependencies": { "@types/find-config": "1.0.4", diff --git a/typescript/package-lock.json b/typescript/package-lock.json index b62d0e48..88d1af2a 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -20,7 +20,7 @@ }, "peerDependencies": { "typescript": "^5.3.3", - "zod": "^3.22.4" + "zod": "^4.4.3" }, "peerDependenciesMeta": { "typescript": { @@ -70,7 +70,7 @@ "dotenv": "^16.3.1", "find-config": "^1.0.0", "typechat": "^0.1.0", - "zod": "^3.22.4" + "zod": "^4.0.0" }, "devDependencies": { "@types/find-config": "1.0.4", @@ -3292,9 +3292,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/typescript/package.json b/typescript/package.json index a57082b6..ec551934 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -21,9 +21,8 @@ }, "scripts": { "build": "tsc -p src", - "test": "npm run build && node --test tests/*.mjs", + "test": "npm run build && tsc -p test && node --test out/validate.test.js out/zod.test.js tests/model.test.mjs", "build-all": "npm run build --workspaces", - "test": "tsc -p test && node --test out/validate.test.js", "prepare": "npm run build-all", "prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"", "postpublish": "node -e \"require('fs').unlinkSync('SECURITY.md')\"", @@ -47,10 +46,9 @@ "README.md", "SECURITY.md" ], - "dependencies": {}, "peerDependencies": { "typescript": "^5.3.3", - "zod": "^3.22.4" + "zod": "^4.4.3" }, "peerDependenciesMeta": { "typescript": { diff --git a/typescript/src/zod/validate.ts b/typescript/src/zod/validate.ts index 4f4cfa26..739c17d8 100644 --- a/typescript/src/zod/validate.ts +++ b/typescript/src/zod/validate.ts @@ -9,11 +9,11 @@ import { TypeChatJsonValidator } from '../typechat'; * the `getTypeName` method obtains the name of the given target type in the schema. * @param schema A schema object where each property provides a name for an associated Zod type. * @param targetType The name in the schema of the target type for JSON validation. - * @returns A `TypeChatJsonValidator>`, where T is the schema and K is the target type name. + * @returns A `TypeChatJsonValidator & object>`, where T is the schema and K is the target type name. */ -export function createZodJsonValidator, K extends keyof T & string>(schema: T, typeName: K): TypeChatJsonValidator> { +export function createZodJsonValidator, K extends keyof T & string>(schema: T, typeName: K): TypeChatJsonValidator & object> { let schemaText: string; - const validator: TypeChatJsonValidator> = { + const validator: TypeChatJsonValidator & object> = { getSchemaText: () => schemaText ??= getZodSchemaAsTypeScript(schema), getTypeName: () => typeName, validate @@ -25,22 +25,28 @@ export function createZodJsonValidator, K ex if (!result.success) { return error(result.error.issues.map(({ path, message }) => `${path.map(key => `[${JSON.stringify(key)}]`).join("")}: ${message}`).join("\"")); } - return success(result.data as z.TypeOf); + return success(result.data as z.infer & object); } } -function getTypeKind(type: z.ZodType) { - return (type._def as z.ZodTypeDef & { typeName: z.ZodFirstPartyTypeKind }).typeName; +// ZodTypeKind is the union of all type-discriminant strings defined by Zod v4. +// `z.core` is part of Zod v4's public API (exported from `zod/v4/classic/external.d.ts`). +// Using this type (rather than plain `string`) means the compiler enforces that +// every case label in switch statements is a legitimate Zod type kind. +type ZodTypeKind = z.core.$ZodTypeDef["type"]; + +function getTypeKind(type: z.ZodType): ZodTypeKind { + return (type._zod.def as z.core.$ZodTypeDef).type; } function getTypeIdentity(type: z.ZodType): object { switch (getTypeKind(type)) { - case z.ZodFirstPartyTypeKind.ZodObject: - return (type._def as z.ZodObjectDef).shape(); - case z.ZodFirstPartyTypeKind.ZodEnum: - return (type._def as z.ZodEnumDef).values; - case z.ZodFirstPartyTypeKind.ZodUnion: - return (type._def as z.ZodUnionDef).options; + case "object": + return (type._zod.def as z.core.$ZodObjectDef).shape; + case "enum": + return (type._zod.def as z.core.$ZodEnumDef).entries; + case "union": + return (type._zod.def as z.core.$ZodUnionDef).options; } return type; } @@ -53,11 +59,10 @@ const enum TypePrecedence { function getTypePrecedence(type: z.ZodType): TypePrecedence { switch (getTypeKind(type)) { - case z.ZodFirstPartyTypeKind.ZodEnum: - case z.ZodFirstPartyTypeKind.ZodUnion: - case z.ZodFirstPartyTypeKind.ZodDiscriminatedUnion: + case "enum": + case "union": // covers both z.union() and z.discriminatedUnion() — Zod v4 merged discriminated unions into the regular union type kind ("ZodDiscriminatedUnion" in v3) return TypePrecedence.Union; - case z.ZodFirstPartyTypeKind.ZodIntersection: + case "intersection": return TypePrecedence.Intersection; } return TypePrecedence.Object; @@ -82,16 +87,16 @@ export function getZodSchemaAsTypeScript(schema: Record): str if (result) { appendNewLine(); } - const description = type._def.description; + const description = type.description; if (description) { for (const comment of description.split("\n")) { append(`// ${comment}`); appendNewLine(); } } - if (getTypeKind(type) === z.ZodFirstPartyTypeKind.ZodObject) { + if (getTypeKind(type) === "object") { append(`interface ${name} `); - appendObjectType(type as z.ZodObject); + appendObjectType(type as z.ZodObject); } else { append(`type ${name} = `); @@ -130,48 +135,53 @@ export function getZodSchemaAsTypeScript(schema: Record): str function appendTypeDefinition(type: z.ZodType) { switch (getTypeKind(type)) { - case z.ZodFirstPartyTypeKind.ZodString: + case "string": return append("string"); - case z.ZodFirstPartyTypeKind.ZodNumber: + case "number": + case "int": return append("number"); - case z.ZodFirstPartyTypeKind.ZodBoolean: + case "boolean": return append("boolean"); - case z.ZodFirstPartyTypeKind.ZodDate: + case "date": return append("Date"); - case z.ZodFirstPartyTypeKind.ZodUndefined: + case "undefined": return append("undefined"); - case z.ZodFirstPartyTypeKind.ZodNull: + case "null": return append("null"); - case z.ZodFirstPartyTypeKind.ZodUnknown: + case "unknown": return append("unknown"); - case z.ZodFirstPartyTypeKind.ZodArray: + case "array": return appendArrayType(type); - case z.ZodFirstPartyTypeKind.ZodObject: + case "object": return appendObjectType(type); - case z.ZodFirstPartyTypeKind.ZodUnion: - return appendUnionOrIntersectionTypes((type._def as z.ZodUnionDef).options, TypePrecedence.Union); - case z.ZodFirstPartyTypeKind.ZodDiscriminatedUnion: - return appendUnionOrIntersectionTypes([...(type._def as z.ZodDiscriminatedUnionDef).options.values()], TypePrecedence.Union); - case z.ZodFirstPartyTypeKind.ZodIntersection: - return appendUnionOrIntersectionTypes((type._def as z.ZodUnionDef).options, TypePrecedence.Intersection); - case z.ZodFirstPartyTypeKind.ZodTuple: + case "union": { // covers both z.union() and z.discriminatedUnion() — Zod v4 merged discriminated unions into the regular union type kind ("ZodDiscriminatedUnion" in v3); both have an `options` array + const unionDef = type._zod.def as z.core.$ZodDiscriminatedUnionDef | z.core.$ZodUnionDef; + return appendUnionOrIntersectionTypes(unionDef.options as readonly z.ZodType[], TypePrecedence.Union); + } + case "intersection": { + const intersectionDef = type._zod.def as z.core.$ZodIntersectionDef; + return appendUnionOrIntersectionTypes([intersectionDef.left as z.ZodType, intersectionDef.right as z.ZodType], TypePrecedence.Intersection); + } + case "tuple": return appendTupleType(type); - case z.ZodFirstPartyTypeKind.ZodRecord: + case "record": return appendRecordType(type); - case z.ZodFirstPartyTypeKind.ZodLiteral: - return appendLiteral((type._def as z.ZodLiteralDef).value); - case z.ZodFirstPartyTypeKind.ZodEnum: - return append((type._def as z.ZodEnumDef).values.map(value => JSON.stringify(value)).join(" | ")); - case z.ZodFirstPartyTypeKind.ZodOptional: - return appendUnionOrIntersectionTypes([(type._def as z.ZodOptionalDef).innerType, z.undefined()], TypePrecedence.Union); - case z.ZodFirstPartyTypeKind.ZodReadonly: + case "literal": { + const litValues = (type._zod.def as z.core.$ZodLiteralDef).values; + return append(litValues.map(v => v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? JSON.stringify(v) : "any").join(" | ")); + } + case "enum": + return append(Object.values((type._zod.def as z.core.$ZodEnumDef).entries).map(value => JSON.stringify(value)).join(" | ")); + case "optional": + return appendUnionOrIntersectionTypes([(type._zod.def as z.core.$ZodOptionalDef).innerType as z.ZodType, z.undefined()], TypePrecedence.Union); + case "readonly": return appendReadonlyType(type); } append("any"); } function appendArrayType(arrayType: z.ZodType) { - appendType((arrayType._def as z.ZodArrayDef).type, TypePrecedence.Object); + appendType((arrayType._zod.def as z.core.$ZodArrayDef).element as z.ZodType, TypePrecedence.Object); append("[]"); } @@ -179,17 +189,27 @@ export function getZodSchemaAsTypeScript(schema: Record): str append("{"); appendNewLine(); indent++; - for (let [name, type] of Object.entries((objectType._def as z.ZodObjectDef).shape())) { - const comment = type.description; - append(name); - if (getTypeKind(type) === z.ZodFirstPartyTypeKind.ZodOptional) { + for (let [name, type] of Object.entries((objectType._zod.def as z.core.$ZodObjectDef).shape) as [string, z.ZodType][]) { + let inlineComment: string | undefined; + if (getTypeKind(type) === "optional") { + const wrapperDescription = type.description; + if (wrapperDescription) { + for (const line of wrapperDescription.split("\n")) { + append(`// ${line}`); + appendNewLine(); + } + } + append(name); append("?"); - type = (type._def as z.ZodOptionalDef).innerType; + type = (type._zod.def as z.core.$ZodOptionalDef).innerType as z.ZodType; + } else { + inlineComment = type.description; + append(name); } append(": "); appendType(type); append(";"); - if (comment) append(` // ${comment}`); + if (inlineComment) append(` // ${inlineComment}`); appendNewLine(); } indent--; @@ -208,10 +228,11 @@ export function getZodSchemaAsTypeScript(schema: Record): str function appendTupleType(tupleType: z.ZodType) { append("["); let first = true; - for (let type of (tupleType._def as z.ZodTupleDef).items) { + const tupleDef = tupleType._zod.def as z.core.$ZodTupleDef; + for (let type of tupleDef.items as z.ZodType[]) { if (!first) append(", "); - if (getTypeKind(type) === z.ZodFirstPartyTypeKind.ZodOptional) { - appendType((type._def as z.ZodOptionalDef).innerType, TypePrecedence.Object); + if (getTypeKind(type) === "optional") { + appendType((type._zod.def as z.core.$ZodOptionalDef).innerType as z.ZodType, TypePrecedence.Object); append("?"); } else { @@ -219,11 +240,11 @@ export function getZodSchemaAsTypeScript(schema: Record): str } first = false; } - const rest = (tupleType._def as z.ZodTupleDef).rest; + const rest = tupleDef.rest; if (rest) { if (!first) append(", "); append("..."); - appendType(rest, TypePrecedence.Object); + appendType(rest as z.ZodType, TypePrecedence.Object); append("[]"); } append("]"); @@ -231,19 +252,15 @@ export function getZodSchemaAsTypeScript(schema: Record): str function appendRecordType(recordType: z.ZodType) { append("Record<"); - appendType((recordType._def as z.ZodRecordDef).keyType); + appendType((recordType._zod.def as z.core.$ZodRecordDef).keyType as z.ZodType); append(", "); - appendType((recordType._def as z.ZodRecordDef).valueType); + appendType((recordType._zod.def as z.core.$ZodRecordDef).valueType as z.ZodType); append(">"); } - function appendLiteral(value: unknown) { - append(typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? JSON.stringify(value) : "any"); - } - function appendReadonlyType(readonlyType: z.ZodType) { append("Readonly<"); - appendType((readonlyType._def as z.ZodReadonlyDef).innerType); + appendType((readonlyType._zod.def as z.core.$ZodReadonlyDef).innerType as z.ZodType); append(">"); } } diff --git a/typescript/test/zod.test.ts b/typescript/test/zod.test.ts new file mode 100644 index 00000000..ad93e44e --- /dev/null +++ b/typescript/test/zod.test.ts @@ -0,0 +1,438 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { createZodJsonValidator, getZodSchemaAsTypeScript } from "../dist/zod/index.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Runs getZodSchemaAsTypeScript on a single named type and returns the result. */ +function schemaOf(name: string, type: T): string { + return getZodSchemaAsTypeScript({ [name]: type }); +} + +// --------------------------------------------------------------------------- +// getZodSchemaAsTypeScript — primitive types +// --------------------------------------------------------------------------- + +describe("getZodSchemaAsTypeScript", () => { + + describe("primitive types emit the correct TypeScript keyword", () => { + + it("z.string() → string", () => { + assert.match(schemaOf("T", z.string()), /type T = string;/); + }); + + it("z.number() → number", () => { + assert.match(schemaOf("T", z.number()), /type T = number;/); + }); + + it("z.boolean() → boolean", () => { + assert.match(schemaOf("T", z.boolean()), /type T = boolean;/); + }); + + it("z.date() → Date", () => { + assert.match(schemaOf("T", z.date()), /type T = Date;/); + }); + + it("z.undefined() → undefined", () => { + assert.match(schemaOf("T", z.undefined()), /type T = undefined;/); + }); + + it("z.null() → null", () => { + assert.match(schemaOf("T", z.null()), /type T = null;/); + }); + + it("z.unknown() → unknown", () => { + assert.match(schemaOf("T", z.unknown()), /type T = unknown;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Array + // ----------------------------------------------------------------------- + + describe("z.array()", () => { + + it("produces an array type", () => { + assert.match(schemaOf("T", z.array(z.string())), /type T = string\[\];/); + }); + + it("produces a nested array type", () => { + assert.match(schemaOf("T", z.array(z.array(z.number()))), /type T = number\[\]\[\];/); + }); + + }); + + // ----------------------------------------------------------------------- + // Object — emitted as interface + // ----------------------------------------------------------------------- + + describe("z.object()", () => { + + it("emits an interface (not a type alias) for object types", () => { + const out = schemaOf("Point", z.object({ x: z.number(), y: z.number() })); + assert.match(out, /^interface Point \{/m); + }); + + it("includes required properties", () => { + const out = schemaOf("Point", z.object({ x: z.number(), y: z.number() })); + assert.match(out, /x: number;/); + assert.match(out, /y: number;/); + }); + + it("marks optional properties with ? and elides the undefined union", () => { + const out = schemaOf("T", z.object({ a: z.string(), b: z.string().optional() })); + assert.match(out, /a: string;/); + assert.match(out, /b\?: string;/); + // The optional field should not be emitted as "b: string | undefined" + assert.doesNotMatch(out, /b: string \| undefined/); + }); + + it("adds inline comment from .describe() on a field", () => { + const out = schemaOf("T", z.object({ + x: z.number().describe("x coordinate"), + })); + assert.match(out, /x: number; \/\/ x coordinate/); + }); + + it("does NOT add comment to field comment if the optional wrapper has a description", () => { + // The description on the optional wrapper should appear before the field, not inline on it + const out = schemaOf("T", z.object({ + size: z.string().optional().describe("The default is 'grande'"), + })); + assert.match(out, /\/\/ The default is 'grande'\s*\r?\n\s*size\?: string;/); + assert.doesNotMatch(out, /size\?: string; \/\/ The default is 'grande'/); + }); + + }); + + // ----------------------------------------------------------------------- + // Union and discriminated union + // ----------------------------------------------------------------------- + + describe("z.union()", () => { + + it("emits a union type", () => { + assert.match(schemaOf("T", z.union([z.string(), z.number()])), /type T = string \| number;/); + }); + + it("handles a three-way union", () => { + const out = schemaOf("T", z.union([z.string(), z.number(), z.boolean()])); + assert.match(out, /string \| number \| boolean/); + }); + + }); + + describe("z.discriminatedUnion()", () => { + + it("emits the options as a union", () => { + const Cat = z.object({ kind: z.literal("cat") }); + const Dog = z.object({ kind: z.literal("dog") }); + const Pet = z.discriminatedUnion("kind", [Cat, Dog]); + const out = schemaOf("Pet", Pet); + // Both variants should appear in the output + assert.match(out, /kind: "cat"/); + assert.match(out, /kind: "dog"/); + }); + + }); + + // ----------------------------------------------------------------------- + // Intersection + // ----------------------------------------------------------------------- + + describe("z.intersection()", () => { + + it("emits an intersection type using &", () => { + const A = z.object({ a: z.string() }); + const B = z.object({ b: z.number() }); + const out = schemaOf("T", z.intersection(A, B)); + assert.match(out, /\{[^}]*a: string[^}]*\} & \{[^}]*b: number[^}]*\}/s); + }); + + }); + + // ----------------------------------------------------------------------- + // Tuple + // ----------------------------------------------------------------------- + + describe("z.tuple()", () => { + + it("emits a simple tuple type", () => { + assert.match(schemaOf("T", z.tuple([z.string(), z.number()])), /type T = \[string, number\];/); + }); + + it("emits a single-element tuple", () => { + assert.match(schemaOf("T", z.tuple([z.boolean()])), /type T = \[boolean\];/); + }); + + it("emits a tuple with a rest element", () => { + const out = schemaOf("T", z.tuple([z.string()]).rest(z.number())); + assert.match(out, /\[string, \.\.\.number\[\]\]/); + }); + + it("emits a tuple with an optional element", () => { + const out = schemaOf("T", z.tuple([z.string(), z.number().optional()])); + assert.match(out, /\[string, number\?\]/); + }); + + }); + + // ----------------------------------------------------------------------- + // Record + // ----------------------------------------------------------------------- + + describe("z.record()", () => { + + it("emits Record", () => { + assert.match(schemaOf("T", z.record(z.string(), z.number())), /type T = Record;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Literal + // ----------------------------------------------------------------------- + + describe("z.literal()", () => { + + it("emits a string literal", () => { + assert.match(schemaOf("T", z.literal("hello")), /type T = "hello";/); + }); + + it("emits a numeric literal", () => { + assert.match(schemaOf("T", z.literal(42)), /type T = 42;/); + }); + + it("emits a boolean literal (true)", () => { + assert.match(schemaOf("T", z.literal(true)), /type T = true;/); + }); + + it("emits a boolean literal (false)", () => { + assert.match(schemaOf("T", z.literal(false)), /type T = false;/); + }); + + it("emits a union for multi-value literal (array form) — new Zod v4 overload", () => { + // z.literal() in Zod v4 accepts an array (ReadonlyArray) as a first-class overload + const out = schemaOf("T", z.literal(["active", "inactive", "pending"])); + assert.match(out, /type T = "active" \| "inactive" \| "pending";/); + }); + + it("emits 'null' for null literal", () => { + // null is a valid Literal in Zod v4 (util.Literal = string | number | boolean | bigint | null | undefined) + const NullLiteral = z.literal(null); + assert.match(schemaOf("T", NullLiteral), /null/); + }); + + }); + + // ----------------------------------------------------------------------- + // Enum + // ----------------------------------------------------------------------- + + describe("z.enum()", () => { + + it("emits a union of string literals", () => { + const out = schemaOf("T", z.enum(["foo", "bar", "baz"])); + assert.match(out, /type T = "foo" \| "bar" \| "baz";/); + }); + + }); + + // ----------------------------------------------------------------------- + // Optional (standalone) + // ----------------------------------------------------------------------- + + describe("z.optional()", () => { + + it("emits T | undefined for a standalone optional", () => { + const out = schemaOf("T", z.optional(z.string())); + assert.match(out, /type T = string \| undefined;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Readonly + // ----------------------------------------------------------------------- + + describe("z.readonly()", () => { + + it("emits Readonly", () => { + assert.match(schemaOf("T", z.readonly(z.string())), /type T = Readonly;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Descriptions + // ----------------------------------------------------------------------- + + describe("type-level .describe()", () => { + + it("emits a line comment before the type declaration", () => { + const out = schemaOf("T", z.object({ x: z.number() }).describe("A 2D point")); + assert.match(out, /\/\/ A 2D point\ninterface T/); + }); + + it("handles a multi-line description by splitting into multiple comment lines", () => { + const out = schemaOf("T", z.string().describe("line one\nline two")); + assert.match(out, /\/\/ line one\n\/\/ line two\ntype T/); + }); + + }); + + // ----------------------------------------------------------------------- + // Named type references + // ----------------------------------------------------------------------- + + describe("named type references", () => { + + it("uses the name of a schema entry when that type appears inline", () => { + const Inner = z.object({ value: z.string() }); + const Outer = z.object({ inner: Inner }); + const out = getZodSchemaAsTypeScript({ Inner, Outer }); + // Outer should reference Inner by name, not inline the definition again + assert.match(out, /inner: Inner;/); + }); + + it("uses enum name in referencing object", () => { + const Status = z.enum(["active", "inactive"]); + const User = z.object({ status: Status }); + const out = getZodSchemaAsTypeScript({ Status, User }); + assert.match(out, /status: Status;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Output format: interface vs type alias + // ----------------------------------------------------------------------- + + describe("output format", () => { + + it("uses 'interface' for object types", () => { + const out = schemaOf("MyObj", z.object({ a: z.string() })); + assert.match(out, /^interface MyObj/m); + assert.doesNotMatch(out, /^type MyObj/m); + }); + + it("uses 'type ... =' for non-object types", () => { + const out = schemaOf("MyStr", z.string()); + assert.match(out, /^type MyStr = string;/m); + assert.doesNotMatch(out, /^interface MyStr/m); + }); + + it("separates multiple type declarations with a blank line", () => { + const out = getZodSchemaAsTypeScript({ + A: z.string(), + B: z.number(), + }); + // There should be a blank line between the two declarations + assert.match(out, /type A = string;\n\ntype B = number;/); + }); + + }); + + // ----------------------------------------------------------------------- + // Fallthrough: unknown type kind → any + // ----------------------------------------------------------------------- + + describe("unknown type kind", () => { + + it("emits 'any' for an unrecognized type kind", () => { + // z.nan() is not explicitly handled — should fall through to 'any' + const nan = z.nan(); + assert.match(schemaOf("T", nan), /any/); + }); + + }); + +}); + +// --------------------------------------------------------------------------- +// createZodJsonValidator +// --------------------------------------------------------------------------- + +describe("createZodJsonValidator", () => { + + const SentimentSchema = { + SentimentResponse: z.object({ + sentiment: z.enum(["negative", "neutral", "positive"]) + .describe("The sentiment of the text"), + }), + }; + + describe("getTypeName()", () => { + it("returns the target type name", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + assert.strictEqual(v.getTypeName(), "SentimentResponse"); + }); + }); + + describe("getSchemaText()", () => { + it("returns the TypeScript source for the schema", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + const text = v.getSchemaText(); + assert.match(text, /interface SentimentResponse/); + assert.match(text, /"negative" \| "neutral" \| "positive"/); + }); + + it("is memoized (returns the same string reference on repeated calls)", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + assert.strictEqual(v.getSchemaText(), v.getSchemaText()); + }); + }); + + describe("validate() — success", () => { + + it("returns success for a valid object", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + const result = v.validate({ sentiment: "positive" }); + assert.ok(result.success, `expected success but got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.data, { sentiment: "positive" }); + }); + + }); + + describe("validate() — failure", () => { + + it("returns an error for an invalid object", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + const result = v.validate({ sentiment: "very-happy" }); + assert.ok(!result.success, "expected failure"); + assert.ok(result.message.length > 0, "expected non-empty error message"); + }); + + it("includes the path in the error message", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + const result = v.validate({ sentiment: 123 }); + assert.ok(!result.success, "expected failure"); + // The path ["sentiment"] should appear in the message + assert.match(result.message, /sentiment/); + }); + + it("fails for an empty object that is missing required fields", () => { + const v = createZodJsonValidator(SentimentSchema, "SentimentResponse"); + const result = v.validate({}); + assert.ok(!result.success, "expected failure for empty object"); + }); + + it("fails for a deeply nested path error and includes the path", () => { + const DeepSchema = { + Root: z.object({ + items: z.array(z.object({ count: z.number() })), + }), + }; + const v = createZodJsonValidator(DeepSchema, "Root"); + const result = v.validate({ items: [{ count: "not-a-number" }] }); + assert.ok(!result.success, "expected failure"); + assert.match(result.message, /items/); + }); + + }); + +});