From 027fe721e4bc7e2255fdb23569d1dc2cc5f8d158 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 19 Apr 2025 11:03:34 +0200 Subject: [PATCH 1/8] Testing z.interface compatibility to IOSchema. --- express-zod-api/tests/io-schema.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 4fc63f548..5bc6c53f3 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,3 +1,4 @@ +import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; import { @@ -15,6 +16,15 @@ describe("I/O Schema and related helpers", () => { expectTypeOf(z.object({}).strict()).toExtend(); expectTypeOf(z.object({}).loose()).toExtend(); }); + test("accepts interface", () => { + expectTypeOf(z.interface({})).toExtend(); + expectTypeOf(z.interface({ "some?": z.string() })).toExtend(); + expectTypeOf(z.interface({}).strip()).toExtend(); + expectTypeOf(z.interface({}).loose()).toExtend(); + expectTypeOf(z.looseInterface({})).toExtend(); + expectTypeOf(z.interface({}).strict()).toExtend(); + expectTypeOf(z.strictInterface({})).toExtend(); + }); test("accepts ez.raw()", () => { expectTypeOf(ez.raw()).toExtend(); expectTypeOf(ez.raw({ something: z.any() })).toExtend(); From 9b903156b8c2a135b0f6ef074fc757e0bd20cf18 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:03:23 +0200 Subject: [PATCH 2/8] TDD: the test that should pass. --- express-zod-api/tests/documentation.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index d1598c949..7858b36f0 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -481,11 +481,13 @@ describe("Documentation", () => { expect(boolean.parse(null)).toBe(false); }); - // @todo switch to z.interface for that + // @todo rename the test test("should handle circular schemas via z.lazy()", () => { - const category: z.ZodObject = z.object({ + const category = z.interface({ name: z.string(), - subcategories: z.lazy(() => category.array()), + get subcategories() { + return z.array(category); + }, }); const spec = new Documentation({ config: sampleConfig, From 4061fab136ff487f6056c86910101d4256887852 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:19:31 +0200 Subject: [PATCH 3/8] Enhancing extractObjectSchema. --- express-zod-api/src/io-schema.ts | 2 ++ express-zod-api/tests/env.spec.ts | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 53bd80a92..255ad8da5 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -36,6 +36,8 @@ export const getFinalEndpointInputSchema = < export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { if (subject instanceof z.ZodObject) return subject; + if (subject instanceof z.ZodInterface) + return z.object(subject._zod.def.shape); // @todo mark optionals if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 7c48b0a84..42ef58360 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -63,6 +63,17 @@ describe("Environment checks", () => { }); }); + describe("Zod new features", () => { + test("interface shape does not contain question marks, but there is a list of them", () => { + const schema = z.interface({ + one: z.boolean(), + "two?": z.boolean(), + }); + expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]); + expect(schema._zod.def.optional).toEqual(["two"]); + }); + }); + describe("Vitest error comparison", () => { test("should distinguish error instances of different classes", () => { expect(createHttpError(500, "some message")).not.toEqual( From aa1a0319ac1f2c82613db15830cb8c8eddd12595 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:30:12 +0200 Subject: [PATCH 4/8] Handling optionals within interface. --- express-zod-api/src/io-schema.ts | 11 +++++++++-- .../tests/__snapshots__/io-schema.spec.ts.snap | 17 +++++++++++++++++ express-zod-api/tests/io-schema.spec.ts | 8 ++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 255ad8da5..7d44e8194 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -36,8 +36,15 @@ export const getFinalEndpointInputSchema = < export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { if (subject instanceof z.ZodObject) return subject; - if (subject instanceof z.ZodInterface) - return z.object(subject._zod.def.shape); // @todo mark optionals + if (subject instanceof z.ZodInterface) { + const { optional } = subject._zod.def; + const mask = R.zipObj(optional, Array(optional.length).fill(true)); + const partial = subject.pick(mask); + const required = subject.omit(mask); + return z + .object(required._zod.def.shape) + .extend(z.object(partial._zod.def.shape).partial()); + } if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index e8b3816fe..31da5d7f7 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -28,6 +28,23 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869: } `; +exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = ` +{ + "properties": { + "one": { + "type": "boolean", + }, + "two": { + "type": "boolean", + }, + }, + "required": [ + "one", + ], + "type": "object", +} +`; + exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = ` IOSchemaError({ "cause": { diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 5bc6c53f3..9560fa9a6 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -350,6 +350,14 @@ describe("I/O Schema and related helpers", () => { }); describe("Zod 4", () => { + test("should handle interfaces with optional props", () => { + expect( + extractObjectSchema( + z.interface({ one: z.boolean(), "two?": z.boolean() }), + ), + ).toMatchSnapshot(); + }); + test("should throw for incompatible ones", () => { expect(() => extractObjectSchema(z.string() as unknown as IOSchema), From 20bd45cbe6b038b3c0b7e39a840419cff1b54ce6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:36:56 +0200 Subject: [PATCH 5/8] Renaming the test. --- express-zod-api/tests/__snapshots__/documentation.spec.ts.snap | 2 +- express-zod-api/tests/documentation.spec.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index df2297a41..a8b2818c6 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1221,7 +1221,7 @@ servers: " `; -exports[`Documentation > Basic cases > should handle circular schemas via z.lazy() 1`] = ` +exports[`Documentation > Basic cases > should handle circular schemas via z.interface() 1`] = ` "openapi: 3.1.0 info: title: Testing Lazy diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 7858b36f0..fa95fedfb 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -481,8 +481,7 @@ describe("Documentation", () => { expect(boolean.parse(null)).toBe(false); }); - // @todo rename the test - test("should handle circular schemas via z.lazy()", () => { + test("should handle circular schemas via z.interface()", () => { const category = z.interface({ name: z.string(), get subcategories() { From 42a8f06461884b7e69827f81e8224af78abd215e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:46:25 +0200 Subject: [PATCH 6/8] Supporting interfaces by Integration - utilizing makeRef. --- express-zod-api/src/zts.ts | 18 ++++++++++++++++++ .../tests/__snapshots__/zts.spec.ts.snap | 1 + express-zod-api/tests/zts.spec.ts | 8 ++++++++ 3 files changed, 27 insertions(+) diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index e45900ee0..d76f98ab4 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -66,6 +66,23 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => { return values.length === 1 ? values[0] : f.createUnionTypeNode(values); }; +const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => + makeAlias(int, () => { + const members = Object.entries(int._zod.def.shape).map( + ([key, value]) => { + const isOptional = int._zod.def.optional.includes(key); + const { description: comment, deprecated: isDeprecated } = + globalRegistry.get(value) || {}; + return makeInterfaceProp(key, next(value), { + comment, + isDeprecated, + isOptional, + }); + }, + ); + return f.createTypeLiteralNode(members); + }); + const onObject: Producer = ( { _zod: { def } }: z.ZodObject, { @@ -233,6 +250,7 @@ const producers: HandlingRules< tuple: onTuple, record: onRecord, object: onObject, + interface: onInterface, literal: onLiteral, intersection: onIntersection, union: onSomeUnion, diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 18a29ea51..4213a12e0 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -9,6 +9,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` }[]; boolean: boolean; circular: SomeType; + circular2: SomeType; union: { number: number; } | "hi"; diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index a71d77298..002ef4350 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -99,6 +99,13 @@ describe("zod-to-ts", () => { }), ); + const circular2 = z.interface({ + name: z.string(), + get subcategories() { + return z.array(circular2); + }, + }); + const example = z.object({ string: z.string(), number: z.number(), @@ -109,6 +116,7 @@ describe("zod-to-ts", () => { ), boolean: z.boolean(), circular, + circular2, union: z.union([z.object({ number: z.number() }), z.literal("hi")]), enum: z.enum(["hi", "bye"]), intersectionWithTransform: z From 0dacc533ca8bd4ce7c02b94b50a2a4613a5732ee Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 17:56:31 +0200 Subject: [PATCH 7/8] Todo: drop optionalPropStyle. --- express-zod-api/src/zts-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 4faba4ebf..32eacb554 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -9,6 +9,7 @@ export interface ZTSContext extends FlatObject { schema: $ZodType | (() => $ZodType), produce: () => ts.TypeNode, ) => ts.TypeNode; + // @todo remove it in favor of z.interface optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } From 4d764afc75ba7b0539e03efaf8147f2b94fdff54 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 18:05:02 +0200 Subject: [PATCH 8/8] Testing interface schema within integration. --- .../__snapshots__/integration.spec.ts.snap | 71 ++++++++++++++++++- express-zod-api/tests/integration.spec.ts | 59 ++++++++------- 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index c227fc318..74210082a 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -586,7 +586,76 @@ export type Request = keyof Input; " `; -exports[`Integration > Should support types variant and handle recursive schemas 1`] = ` +exports[`Integration > Should support types variant and handle recursive schemas 0 1`] = ` +"type Type1 = { + name: string; + features: Type1; +}; + +type SomeOf = T[keyof T]; + +/** post /v1/test */ +type PostV1TestInput = { + features: Type1; +}; + +/** post /v1/test */ +type PostV1TestPositiveVariant1 = { + status: "success"; + data: {}; +}; + +/** post /v1/test */ +interface PostV1TestPositiveResponseVariants { + 200: PostV1TestPositiveVariant1; +} + +/** post /v1/test */ +type PostV1TestNegativeVariant1 = { + status: "error"; + error: { + message: string; + }; +}; + +/** post /v1/test */ +interface PostV1TestNegativeResponseVariants { + 400: PostV1TestNegativeVariant1; +} + +export type Path = "/v1/test"; + +export type Method = "get" | "post" | "put" | "delete" | "patch"; + +export interface Input { + /** @deprecated */ + "post /v1/test": PostV1TestInput; +} + +export interface PositiveResponse { + /** @deprecated */ + "post /v1/test": SomeOf; +} + +export interface NegativeResponse { + /** @deprecated */ + "post /v1/test": SomeOf; +} + +export interface EncodedResponse { + /** @deprecated */ + "post /v1/test": PostV1TestPositiveResponseVariants & PostV1TestNegativeResponseVariants; +} + +export interface Response { + /** @deprecated */ + "post /v1/test": PositiveResponse["post /v1/test"] | NegativeResponse["post /v1/test"]; +} + +export type Request = keyof Input;" +`; + +exports[`Integration > Should support types variant and handle recursive schemas 1 1`] = ` "type Type1 = { name: string; features: Type1; diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 063ce099d..c71ce30b5 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -9,33 +9,42 @@ import { } from "../src"; describe("Integration", () => { - test("Should support types variant and handle recursive schemas", () => { - const recursiveSchema: z.ZodTypeAny = z.lazy(() => - z.object({ - name: z.string(), - features: recursiveSchema, - }), - ); + const recursive1: z.ZodTypeAny = z.lazy(() => + z.object({ + name: z.string(), + features: recursive1, + }), + ); + const recursive2 = z.interface({ + name: z.string(), + get features() { + return recursive2; + }, + }); - const client = new Integration({ - variant: "types", - routing: { - v1: { - test: defaultEndpointsFactory - .build({ - method: "post", - input: z.object({ - features: recursiveSchema, - }), - output: z.object({}), - handler: async () => ({}), - }) - .deprecated(), + test.each([recursive1, recursive2])( + "Should support types variant and handle recursive schemas %#", + (recursiveSchema) => { + const client = new Integration({ + variant: "types", + routing: { + v1: { + test: defaultEndpointsFactory + .build({ + method: "post", + input: z.object({ + features: recursiveSchema, + }), + output: z.object({}), + handler: async () => ({}), + }) + .deprecated(), + }, }, - }, - }); - expect(client.print()).toMatchSnapshot(); - }); + }); + expect(client.print()).toMatchSnapshot(); + }, + ); test("Should treat optionals the same way as z.infer() by default", async () => { const client = new Integration({