Repository: samchungy/zod-openapi (ts)

Issue:
current/previous properties make the zod schema non-serializable
I've encountered a serialization issue for zod schema when zod-openapi is used (using it with moleculer and moleculer-zod-validator). Without zod-openapi it serializes fine.

The .current and .previous (https://github.com/samchungy/zod-openapi/blob/7acdad5c86ea3bc83d588607fd43c691113f7f35/src/extendZod.ts#L45) include self-references and they only seem to be used in the specific case of createSchemaRef (https://github.com/samchungy/zod-openapi/blob/7acdad5c86ea3bc83d588607fd43c691113f7f35/src/create/schema/index.ts#L229).

I managed to work around this issue by deeply removing those two fields from schema/shapes. There's also omit() (https://github.com/samchungy/zod-openapi/blob/7acdad5c86ea3bc83d588607fd43c691113f7f35/src/extendZod.ts#L115) which seem to do a similar thing although it also integrates with Zod's own omit() which is something that I'd like to avoid.

Does this bother anyone? I'd be happy to make a PR after aligning with the @samchungy and the maintainers on the direction and design.


Pull request description:
Replace current and previous properties with symbols to allow JSON serialization of zod schema
Avoid serializing them and encountering issues circular references

Resolves #392


Patch:
diff --git a/package.json b/package.json
index 5f6bca4..142462c 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,6 @@
     "entryPoint": "src/index.ts",
     "template": "oss-npm-package",
     "type": "package",
-    "version": "9.0.0-main-20240928013837"
+    "version": "9.1.0"
   }
 }
diff --git a/src/create/schema/index.ts b/src/create/schema/index.ts
index 7a00d3a..5f69f07 100644
--- a/src/create/schema/index.ts
+++ b/src/create/schema/index.ts
@@ -1,5 +1,6 @@
 import type { ZodType, ZodTypeDef } from 'zod';
 
+import { currentSymbol, previousSymbol } from '../../extendZodTypes';
 import type { oas30, oas31 } from '../../openapi3-ts/dist';
 import {
   type ComponentsObject,
@@ -220,18 +221,22 @@ export const createSchemaOrRef = <
     return existingRef;
   }
 
-  const previous = zodSchema._def.zodOpenApi?.previous
-    ? (createSchemaOrRef(zodSchema._def.zodOpenApi.previous, state, true) as
-        | RefObject
-        | undefined)
+  const previous = zodSchema._def.zodOpenApi?.[previousSymbol]
+    ? (createSchemaOrRef(
+        zodSchema._def.zodOpenApi[previousSymbol],
+        state,
+        true,
+      ) as RefObject | undefined)
     : undefined;
 
   const current =
-    zodSchema._def.zodOpenApi?.current &&
-    zodSchema._def.zodOpenApi.current !== zodSchema
-      ? (createSchemaOrRef(zodSchema._def.zodOpenApi.current, state, true) as
-          | RefObject
-          | undefined)
+    zodSchema._def.zodOpenApi?.[currentSymbol] &&
+    zodSchema._def.zodOpenApi[currentSymbol] !== zodSchema
+      ? (createSchemaOrRef(
+          zodSchema._def.zodOpenApi[currentSymbol],
+          state,
+          true,
+        ) as RefObject | undefined)
       : undefined;
 
   const ref = zodSchema._def.zodOpenApi?.openapi?.ref ?? component?.ref;
diff --git a/src/entries/extend.ts b/src/entries/extend.ts
index fea7d7e..ad509e9 100644
--- a/src/entries/extend.ts
+++ b/src/entries/extend.ts
@@ -4,5 +4,4 @@ import { extendZodWithOpenApi } from '../extendZod';
 
 extendZodWithOpenApi(z);
 
-// eslint-disable-next-line @typescript-eslint/consistent-type-exports
 export * from '../extendZodTypes'; // compatibility with < TS 5.0 as the export type * syntax is not supported
diff --git a/src/extendZod.ts b/src/extendZod.ts
index 4738f1c..149daee 100644
--- a/src/extendZod.ts
+++ b/src/extendZod.ts
@@ -1,6 +1,6 @@
 import type { ZodRawShape, ZodTypeDef, z } from 'zod';
 
-import './extendZodTypes';
+import { currentSymbol, previousSymbol } from './extendZodTypes';
 
 type ZodOpenApiMetadataDef = NonNullable<ZodTypeDef['zodOpenApi']>;
 type ZodOpenApiMetadata = ZodOpenApiMetadataDef['openapi'];
@@ -42,11 +42,11 @@ export function extendZodWithOpenApi(zod: typeof z) {
     });
 
     // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
-    result._def.zodOpenApi.current = result;
+    result._def.zodOpenApi[currentSymbol] = result;
 
     if (zodOpenApi) {
       // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
-      result._def.zodOpenApi.previous = this;
+      result._def.zodOpenApi[previousSymbol] = this;
     }
 
     // eslint-disable-next-line @typescript-eslint/no-unsafe-return
@@ -63,13 +63,13 @@ export function extendZodWithOpenApi(zod: typeof z) {
     if (def.zodOpenApi) {
       const cloned = { ...def.zodOpenApi };
       cloned.openapi = mergeOpenApi({ description: args[0] }, cloned.openapi);
-      cloned.previous = this;
-      cloned.current = result;
+      cloned[previousSymbol] = this;
+      cloned[currentSymbol] = result;
       def.zodOpenApi = cloned;
     } else {
       def.zodOpenApi = {
         openapi: { description: args[0] },
-        current: result,
+        [currentSymbol]: result,
       };
     }
 
@@ -88,11 +88,11 @@ export function extendZodWithOpenApi(zod: typeof z) {
     if (zodOpenApi) {
       const cloned = { ...zodOpenApi };
       cloned.openapi = mergeOpenApi({}, cloned.openapi);
-      cloned.previous = this;
+      cloned[previousSymbol] = this;
       extendResult._def.zodOpenApi = cloned;
     } else {
       extendResult._def.zodOpenApi = {
-        previous: this,
+        [previousSymbol]: this,
       };
     }
 
@@ -112,8 +112,8 @@ export function extendZodWithOpenApi(zod: typeof z) {
     if (zodOpenApi) {
       const cloned = { ...zodOpenApi };
       cloned.openapi = mergeOpenApi({}, cloned.openapi);
-      delete cloned.previous;
-      delete cloned.current;
+      delete cloned[previousSymbol];
+      delete cloned[currentSymbol];
       omitResult._def.zodOpenApi = cloned;
     }
 
@@ -133,8 +133,8 @@ export function extendZodWithOpenApi(zod: typeof z) {
     if (zodOpenApi) {
       const cloned = { ...zodOpenApi };
       cloned.openapi = mergeOpenApi({}, cloned.openapi);
-      delete cloned.previous;
-      delete cloned.current;
+      delete cloned[previousSymbol];
+      delete cloned[currentSymbol];
       pickResult._def.zodOpenApi = cloned;
     }
     // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
diff --git a/src/extendZodTypes.ts b/src/extendZodTypes.ts
index c1762fc..c4ff49d 100644
--- a/src/extendZodTypes.ts
+++ b/src/extendZodTypes.ts
@@ -7,6 +7,9 @@ type SchemaObject = oas30.SchemaObject & oas31.SchemaObject;
 
 type ReplaceDate<T> = T extends Date ? Date | string : T;
 
+export const currentSymbol = Symbol('current');
+export const previousSymbol = Symbol('previous');
+
 /**
  * zod-openapi metadata
  */
@@ -77,12 +80,12 @@ interface ZodOpenApiMetadataDef {
   /**
    * Used to keep track of the Zod Schema had `.openapi` called on it
    */
-  current?: ZodTypeAny;
+  [currentSymbol]?: ZodTypeAny;
   /**
    * Used to keep track of the previous Zod Schema that had `.openapi` called on it if another `.openapi` is called.
    * This can also be present when .extend is called on an object.
    */
-  previous?: ZodTypeAny;
+  [previousSymbol]?: ZodTypeAny;
 }
 
 interface ZodOpenApiExtendMetadata {
diff --git a/tsconfig.json b/tsconfig.json
index ff183e6..59cef52 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,6 +5,6 @@
     "removeComments": false,
     "target": "ES2022"
   },
-  "exclude": ["lib*/**/*", "crackle.config.ts"],
+  "exclude": ["lib*/**/*", "crackle.config.ts", "dist", "api", "extend"],
   "extends": "skuba/config/tsconfig.json"
 }


Test patch:
diff --git a/src/create/schema/metadata.test.ts b/src/create/schema/metadata.test.ts
index c415adf..c61860c 100644
--- a/src/create/schema/metadata.test.ts
+++ b/src/create/schema/metadata.test.ts
@@ -253,10 +253,10 @@ describe('enhanceWithMetadata', () => {
               "coerce": false,
               "typeName": "ZodString",
               "zodOpenApi": {
-                "current": [Circular],
                 "openapi": {
                   "ref": "foo",
                 },
+                Symbol(current): [Circular],
               },
             },
             "and": [Function],
@@ -339,10 +339,10 @@ describe('enhanceWithMetadata', () => {
               "coerce": false,
               "typeName": "ZodString",
               "zodOpenApi": {
-                "current": [Circular],
                 "openapi": {
                   "ref": "foo",
                 },
+                Symbol(current): [Circular],
               },
             },
             "and": [Function],
@@ -604,10 +604,10 @@ describe('enhanceWithMetadata', () => {
               "coerce": false,
               "typeName": "ZodString",
               "zodOpenApi": {
-                "current": [Circular],
                 "openapi": {
                   "ref": "foo",
                 },
+                Symbol(current): [Circular],
               },
             },
             "and": [Function],
@@ -690,10 +690,10 @@ describe('enhanceWithMetadata', () => {
               "coerce": false,
               "typeName": "ZodString",
               "zodOpenApi": {
-                "current": [Circular],
                 "openapi": {
                   "ref": "foo",
                 },
+                Symbol(current): [Circular],
               },
             },
             "and": [Function],
@@ -934,4 +934,11 @@ describe('enhanceWithMetadata', () => {
 ]
 `);
   });
+
+  it('does not fail JSON serialization', () => {
+    const FooSchema = z.string().openapi({ ref: 'foo' });
+    expect(() => {
+      JSON.stringify(FooSchema);
+    }).not.toThrow();
+  });
 });
diff --git a/src/extendZod.test.ts b/src/extendZod.test.ts
index a39bb68..831ee36 100644
--- a/src/extendZod.test.ts
+++ b/src/extendZod.test.ts
@@ -2,6 +2,7 @@ import { z } from 'zod';
 
 import { createSchema } from './create/schema/single';
 import { extendZodWithOpenApi } from './extendZod';
+import { currentSymbol, previousSymbol } from './extendZodTypes';
 
 extendZodWithOpenApi(z);
 
@@ -22,7 +23,8 @@ describe('extendZodWithOpenApi', () => {
     expect(a._def.zodOpenApi?.openapi?.description).toBe('test');
     expect(b._def.zodOpenApi?.openapi?.description).toBe('test2');
     expect(
-      b._def.zodOpenApi?.previous?._def.zodOpenApi?.openapi?.description,
+      b._def.zodOpenApi?.[previousSymbol]?._def.zodOpenApi?.openapi
+        ?.description,
     ).toBe('test');
   });
 
@@ -30,8 +32,8 @@ describe('extendZodWithOpenApi', () => {
     const a = z.string().openapi({ ref: 'a' });
     const b = a.uuid();
 
-    expect(a._def.zodOpenApi?.current).toBe(a);
-    expect(b._def.zodOpenApi?.current).toBe(a);
+    expect(a._def.zodOpenApi?.[currentSymbol]).toBe(a);
+    expect(b._def.zodOpenApi?.[currentSymbol]).toBe(a);
   });
 
   it('adds ._def.zodOpenApi.openapi fields to a zod type', () => {
@@ -53,7 +55,7 @@ describe('extendZodWithOpenApi', () => {
     const b = a.extend({ b: z.string() });
 
     expect(a._def.zodOpenApi?.openapi?.ref).toBe('a');
-    expect(b._def.zodOpenApi?.previous).toStrictEqual(a);
+    expect(b._def.zodOpenApi?.[previousSymbol]).toStrictEqual(a);
   });
 
   it('removes previous openapi ref for an object when .omit or .pick is used', () => {
@@ -74,7 +76,7 @@ describe('extendZodWithOpenApi', () => {
     });
 
     expect(a._def.zodOpenApi?.openapi?.ref).toBe('a');
-    expect(b._def.zodOpenApi?.previous).toStrictEqual(a);
+    expect(b._def.zodOpenApi?.[previousSymbol]).toStrictEqual(a);
     expect(c._def.zodOpenApi?.openapi).toEqual({});
     expect(d._def.zodOpenApi?.openapi).toEqual({});