Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion typescript/examples/coffeeShop-zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@
},
"scripts": {
"build": "tsc -p src",
"test": "npm run build && node --test tests/*.mjs",
"test": "tsc -p test && node --test out/validate.test.js out/zod.test.js",
Comment thread
robgruen marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f2dcb80. tests/model.test.mjs is now included in the npm test command: 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')\"",
Expand All @@ -47,10 +46,9 @@
"README.md",
"SECURITY.md"
],
"dependencies": {},
"peerDependencies": {
"typescript": "^5.3.3",
"zod": "^3.22.4"
"zod": "^4.4.3"
},
"peerDependenciesMeta": {
"typescript": {
Expand Down
121 changes: 66 additions & 55 deletions typescript/src/zod/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.TypeOf<T[K]>>`, where T is the schema and K is the target type name.
* @returns A `TypeChatJsonValidator<z.infer<T[K]> & object>`, where T is the schema and K is the target type name.
*/
export function createZodJsonValidator<T extends Record<string, z.ZodType>, K extends keyof T & string>(schema: T, typeName: K): TypeChatJsonValidator<z.TypeOf<T[K]>> {
export function createZodJsonValidator<T extends Record<string, z.ZodType>, K extends keyof T & string>(schema: T, typeName: K): TypeChatJsonValidator<z.infer<T[K]> & object> {
let schemaText: string;
const validator: TypeChatJsonValidator<z.TypeOf<T[K]>> = {
const validator: TypeChatJsonValidator<z.infer<T[K]> & object> = {
getSchemaText: () => schemaText ??= getZodSchemaAsTypeScript(schema),
getTypeName: () => typeName,
validate
Expand All @@ -25,22 +25,28 @@ export function createZodJsonValidator<T extends Record<string, z.ZodType>, 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<T[K]>);
return success(result.data as z.infer<T[K]> & 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;
}
Expand All @@ -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;
Expand All @@ -82,16 +87,16 @@ export function getZodSchemaAsTypeScript(schema: Record<string, z.ZodType>): 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<z.ZodRawShape>);
appendObjectType(type as z.ZodObject);
}
else {
append(`type ${name} = `);
Expand Down Expand Up @@ -130,61 +135,66 @@ export function getZodSchemaAsTypeScript(schema: Record<string, z.ZodType>): str

function appendTypeDefinition(type: z.ZodType) {
switch (getTypeKind(type)) {
case z.ZodFirstPartyTypeKind.ZodString:
Comment thread
robgruen marked this conversation as resolved.
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<string>).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":
Comment on lines +164 to +165
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 962b334. Added v === null to the check before the typeof guards — JSON.stringify(null) produces "null", so z.literal(null) now correctly emits null instead of any. Updated the test that was asserting the old behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 962b334. Added v === null to the condition so JSON.stringify(null)"null" TypeScript type, instead of falling through to "any". Updated the test accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b15235d. The literal case now handles null by checking v === null before the typeof check, so JSON.stringify(null) emits "null" correctly. The dead appendLiteral helper (which had the same null bug) was also removed to eliminate duplication.

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<z.core.util.Literal>).values;
return append(litValues.map(v => 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("[]");
}

function appendObjectType(objectType: z.ZodType) {
append("{");
appendNewLine();
indent++;
for (let [name, type] of Object.entries((objectType._def as z.ZodObjectDef).shape())) {
for (let [name, type] of Object.entries((objectType._zod.def as z.core.$ZodObjectDef).shape) as [string, z.ZodType][]) {
const comment = type.description;
append(name);
if (getTypeKind(type) === z.ZodFirstPartyTypeKind.ZodOptional) {
if (getTypeKind(type) === "optional") {
append("?");
type = (type._def as z.ZodOptionalDef).innerType;
type = (type._zod.def as z.core.$ZodOptionalDef).innerType as z.ZodType;
}
append(": ");
appendType(type);
Expand All @@ -208,32 +218,33 @@ export function getZodSchemaAsTypeScript(schema: Record<string, z.ZodType>): str
function appendTupleType(tupleType: z.ZodType) {
append("[");
let first = true;
for (let type of (tupleType._def as z.ZodTupleDef<z.ZodTupleItems, z.ZodType>).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 {
appendType(type);
}
first = false;
}
const rest = (tupleType._def as z.ZodTupleDef<z.ZodTupleItems, z.ZodType>).rest;
const rest = tupleDef.rest;
if (rest) {
if (!first) append(", ");
append("...");
appendType(rest, TypePrecedence.Object);
appendType(rest as z.ZodType, TypePrecedence.Object);
append("[]");
}
append("]");
}

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(">");
}

Expand All @@ -243,7 +254,7 @@ export function getZodSchemaAsTypeScript(schema: Record<string, z.ZodType>): str

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(">");
}
}
Loading
Loading