Repository: RobinTail/express-zod-api
Pull request #757 (2022-12-23 15:11:35)

Better client types producers
The aim is to improve zts producers and align them with OpenAPI depicters. 

RobinTail: It looks fine. I just need to write changelog, but I'm too lazy for this today :)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bca535be..67b22bbdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
 
 ## Version 8
 
+### v8.9.0
+
+- Fixes of the documentation generator (OpenAPI).
+  - Transformations in the `output` schema:
+    - If failed to figure out their output type, now depicted as `any`.
+    - No excessive properties are inherited from their input types.
+- Improvements of the frontend client generator
+  - Achieving the similarity with the OpenAPI generator.
+  - Transformations in the `output` schema are not recognized and typed, similar to OpenAPI generator.
+  - The `coerce` feature in output schema now does not lead to marking the property as optional.
+
 ### v8.8.2
 
 - No new features, no any fixes.
@@ -37,7 +48,7 @@ after:
 
 ### v8.8.0
 
-- First step on generating better Typescript from your IO schemas.
+- First step on generating better types from your IO schemas for the frontend client.
   - I rewrote and refactored the functionality of `zod-to-ts` within the library.
   - Using the abstract schema walker I made in the previous release.
   - In general, I'm aiming to achieve the consistency between OpenAPI and Client generators.
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index 0951f465..0600d5f7 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -210,6 +210,32 @@ export function hasUpload(schema: z.ZodTypeAny): boolean {
   return false;
 }
 
+/**
+ * @desc isNullable() and isOptional() validate the schema's input
+ * @desc They always return true in case of coercion, which should be taken into account when depicting response
+ */
+export const hasCoercion = (schema: z.ZodType): boolean =>
+  "coerce" in schema._def && typeof schema._def.coerce === "boolean"
+    ? schema._def.coerce
+    : false;
+
+export const tryToTransform = ({
+  effect,
+  sample,
+}: {
+  effect: z.Effect<any> & { type: "transform" };
+  sample: any;
+}) => {
+  try {
+    return typeof effect.transform(sample, {
+      addIssue: () => {},
+      path: [],
+    });
+  } catch (e) {
+    return undefined;
+  }
+};
+
 // obtaining the private helper type from Zod
 export type ErrMessage = Exclude<
   Parameters<typeof z.ZodString.prototype.email>[0],
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 82fff04a..65420e56 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -18,8 +18,10 @@ import {
   ArrayElement,
   getExamples,
   getRoutePathParams,
+  hasCoercion,
   hasTopLevelTransformingEffect,
   routePathParamsRegex,
+  tryToTransform,
 } from "./common-helpers";
 import { InputSources, TagsConfig } from "./config-type";
 import { ZodDateIn, isoDateRegex } from "./date-in-schema";
@@ -65,6 +67,19 @@ const shortDescriptionLimit = 50;
 const isoDateDocumentationUrl =
   "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString";
 
+const samples: Record<
+  Exclude<NonNullable<SchemaObject["type"]>, Array<any>>,
+  any
+> = {
+  integer: 0,
+  number: 0,
+  string: "",
+  boolean: false,
+  object: {},
+  null: null,
+  array: [],
+};
+
 /* eslint-disable @typescript-eslint/no-use-before-define */
 
 export const reformatParamsInPath = (path: string) =>
@@ -432,43 +447,29 @@ export const depictObjectProperties = ({
   );
 };
 
+const makeSample = (depicted: SchemaObject) => {
+  const type = (
+    Array.isArray(depicted.type) ? depicted.type[0] : depicted.type
+  ) as keyof typeof samples;
+  return samples?.[type];
+};
+
 export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
   schema,
   isResponse,
   next,
 }) => {
-  const input = next({ schema: schema._def.schema });
-  const effect = schema._def.effect;
-  if (isResponse && effect && effect.type === "transform") {
-    let output = "undefined";
-    try {
-      output = typeof effect.transform(
-        ["integer", "number"].includes(`${input.type}`)
-          ? 0
-          : "string" === input.type
-          ? ""
-          : "boolean" === input.type
-          ? false
-          : "object" === input.type
-          ? {}
-          : "null" === input.type
-          ? null
-          : "array" === input.type
-          ? []
-          : undefined,
-        { addIssue: () => {}, path: [] }
-      );
-    } catch (e) {
-      /**/
+  const input = next({ schema: schema.innerType() });
+  const { effect } = schema._def;
+  if (isResponse && effect.type === "transform") {
+    const outputType = tryToTransform({ effect, sample: makeSample(input) });
+    if (outputType && ["number", "string", "boolean"].includes(outputType)) {
+      return { type: outputType as "number" | "string" | "boolean" };
+    } else {
+      return next({ schema: z.any() });
     }
-    return {
-      ...input,
-      ...(["number", "string", "boolean"].includes(output) && {
-        type: output as "number" | "string" | "boolean",
-      }),
-    };
   }
-  if (!isResponse && effect && effect.type === "preprocess") {
+  if (!isResponse && effect.type === "preprocess") {
     const { type: inputType, ...rest } = input;
     return {
       ...rest,
@@ -630,15 +631,6 @@ export const depicters: HandlingRules<SchemaObject, OpenAPIContext> = {
   ZodPipeline: depictPipeline,
 };
 
-/**
- * @desc isNullable() and isOptional() validate the schema's input
- * @desc They always return true in case of coercion, which should be taken into account when depicting response
- */
-export const hasCoercion = (schema: z.ZodType): boolean =>
-  "coerce" in schema._def && typeof schema._def.coerce === "boolean"
-    ? schema._def.coerce
-    : false;
-
 export const onEach: Depicter<z.ZodTypeAny, "last"> = ({
   schema,
   isResponse,
diff --git a/src/zts.ts b/src/zts.ts
index f8833ceb..1b4b3895 100644
--- a/src/zts.ts
+++ b/src/zts.ts
@@ -26,6 +26,7 @@
 
 import ts from "typescript";
 import { z } from "zod";
+import { hasCoercion, tryToTransform } from "./common-helpers";
 import { HandlingRules, walkSchema } from "./schema-walker";
 import {
   LiteralType,
@@ -37,6 +38,16 @@ import {
 
 const { factory: f } = ts;
 
+const samples: Partial<Record<ts.KeywordTypeSyntaxKind, any>> = {
+  [ts.SyntaxKind.AnyKeyword]: "",
+  [ts.SyntaxKind.BigIntKeyword]: BigInt(0),
+  [ts.SyntaxKind.BooleanKeyword]: false,
+  [ts.SyntaxKind.NumberKeyword]: 0,
+  [ts.SyntaxKind.ObjectKeyword]: {},
+  [ts.SyntaxKind.StringKeyword]: "",
+  [ts.SyntaxKind.UndefinedKeyword]: undefined,
+};
+
 const onLiteral: Producer<z.ZodLiteral<LiteralType>> = ({
   schema: { value },
 }) =>
@@ -50,14 +61,16 @@ const onLiteral: Producer<z.ZodLiteral<LiteralType>> = ({
       : f.createStringLiteral(value)
   );
 
-// @todo align treating optionals
 const onObject: Producer<z.ZodObject<z.ZodRawShape>> = ({
   schema: { shape },
+  isResponse,
   next,
 }) => {
   const members = Object.entries(shape).map<ts.TypeElement>(([key, value]) => {
-    const { typeName: propTypeName } = value._def;
-    const isOptional = propTypeName === "ZodOptional" || value.isOptional();
+    const isOptional =
+      isResponse && hasCoercion(value)
+        ? value instanceof z.ZodOptional
+        : value.isOptional();
     const propertySignature = f.createPropertySignature(
       undefined,
       makePropertyIdentifier(key),
@@ -92,9 +105,34 @@ const onSomeUnion: Producer<
 > = ({ schema: { options }, next }) =>
   f.createUnionTypeNode(options.map((option) => next({ schema: option })));
 
-// @todo implement effects handling
-const onEffects: Producer<z.ZodEffects<any>> = ({ schema, next }) =>
-  next({ schema: schema._def.schema });
+const makeSample = (produced: ts.TypeNode) =>
+  samples?.[produced.kind as keyof typeof samples];
+
+const onEffects: Producer<z.ZodEffects<z.ZodTypeAny>> = ({
+  schema,
+  next,
+  isResponse,
+}) => {
+  const input = next({ schema: schema.innerType() });
+  const effect = schema._def.effect;
+  if (isResponse && effect.type === "transform") {
+    const outputType = tryToTransform({ effect, sample: makeSample(input) });
+    const resolutions: Partial<
+      Record<NonNullable<typeof outputType>, ts.KeywordTypeSyntaxKind>
+    > = {
+      number: ts.SyntaxKind.NumberKeyword,
+      bigint: ts.SyntaxKind.BigIntKeyword,
+      boolean: ts.SyntaxKind.BooleanKeyword,
+      string: ts.SyntaxKind.StringKeyword,
+      undefined: ts.SyntaxKind.UndefinedKeyword,
+      object: ts.SyntaxKind.ObjectKeyword,
+    };
+    return f.createKeywordTypeNode(
+      (outputType && resolutions[outputType]) || ts.SyntaxKind.AnyKeyword
+    );
+  }
+  return input;
+};
 
 const onNativeEnum: Producer<z.ZodNativeEnum<z.EnumLike>> = ({ schema }) =>
   f.createUnionTypeNode(


Test patch:
diff --git a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
index 0030ba67..afdc6e06 100644
--- a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
@@ -165,6 +165,20 @@ exports[`Open API helpers depictEffect() should depict as string (preprocess) 1`
 }
 `;
 
+exports[`Open API helpers depictEffect() should handle edge cases 1`] = `
+{
+  "format": "any",
+  "nullable": true,
+}
+`;
+
+exports[`Open API helpers depictEffect() should handle edge cases 2`] = `
+{
+  "format": "any",
+  "nullable": true,
+}
+`;
+
 exports[`Open API helpers depictEnum() should set type and enum properties 1`] = `
 {
   "enum": [
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index bebcd906..8eef9e60 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -574,11 +574,6 @@ paths:
                     properties:
                       numericStr:
                         type: string
-                        format: double
-                        minimum: 5e-324
-                        exclusiveMinimum: false
-                        maximum: 1.7976931348623157e+308
-                        exclusiveMaximum: false
                     required:
                       - numericStr
                     example:
@@ -670,11 +665,6 @@ paths:
                     properties:
                       numericStr:
                         type: string
-                        format: double
-                        minimum: 5e-324
-                        exclusiveMinimum: false
-                        maximum: 1.7976931348623157e+308
-                        exclusiveMaximum: false
                     required:
                       - numericStr
                     example:
@@ -771,11 +761,6 @@ paths:
                     properties:
                       numericStr:
                         type: string
-                        format: double
-                        minimum: 5e-324
-                        exclusiveMinimum: false
-                        maximum: 1.7976931348623157e+308
-                        exclusiveMaximum: false
                         example: "123"
                     required:
                       - numericStr
diff --git a/tests/unit/__snapshots__/zts.spec.ts.snap b/tests/unit/__snapshots__/zts.spec.ts.snap
index bbf3736c..e815836d 100644
--- a/tests/unit/__snapshots__/zts.spec.ts.snap
+++ b/tests/unit/__snapshots__/zts.spec.ts.snap
@@ -130,6 +130,14 @@ exports[`zod-to-ts z.discriminatedUnion() outputs correct typescript 1`] = `
 }"
 `;
 
+exports[`zod-to-ts z.effect() transformations should handle an error within the transformation 1`] = `"any"`;
+
+exports[`zod-to-ts z.effect() transformations should handle unsupported transformation in response 1`] = `"any"`;
+
+exports[`zod-to-ts z.effect() transformations should produce the schema type intact 1`] = `"number"`;
+
+exports[`zod-to-ts z.effect() transformations should produce the schema type transformed 1`] = `"string"`;
+
 exports[`zod-to-ts z.literal() Should produce the correct typescript 0 1`] = `""test""`;
 
 exports[`zod-to-ts z.literal() Should produce the correct typescript 1 1`] = `"true"`;
diff --git a/tests/unit/common-helpers.spec.ts b/tests/unit/common-helpers.spec.ts
index da2116d2..fddd8feb 100644
--- a/tests/unit/common-helpers.spec.ts
+++ b/tests/unit/common-helpers.spec.ts
@@ -8,6 +8,7 @@ import {
   getMessageFromError,
   getRoutePathParams,
   getStatusCodeFromError,
+  hasCoercion,
   hasTopLevelTransformingEffect,
   hasUpload,
   isLoggerConfig,
@@ -501,4 +502,18 @@ describe("Common Helpers", () => {
       expect(result.message).toBe(expected);
     });
   });
+
+  describe("hasCoercion", () => {
+    test.each([
+      { schema: z.string(), coercion: false },
+      { schema: z.coerce.string(), coercion: true },
+      { schema: z.boolean({ coerce: true }), coercion: true },
+      { schema: z.custom(), coercion: false },
+    ])(
+      "should check the presence and value of coerce prop %#",
+      ({ schema, coercion }) => {
+        expect(hasCoercion(schema)).toBe(coercion);
+      }
+    );
+  });
 });
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 20d749ff..394040cd 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -48,7 +48,6 @@ import {
   excludeExampleFromDepiction,
   excludeParamsFromDepiction,
   extractObjectSchema,
-  hasCoercion,
   onEach,
   onMissing,
   reformatParamsInPath,
@@ -312,20 +311,6 @@ describe("Open API helpers", () => {
     });
   });
 
-  describe("hasCoercion", () => {
-    test.each([
-      { schema: z.string(), coercion: false },
-      { schema: z.coerce.string(), coercion: true },
-      { schema: z.boolean({ coerce: true }), coercion: true },
-      { schema: z.custom(), coercion: false },
-    ])(
-      "should check the presence and value of coerce prop %#",
-      ({ schema, coercion }) => {
-        expect(hasCoercion(schema)).toBe(coercion);
-      }
-    );
-  });
-
   describe("depictOptional()", () => {
     test.each<OpenAPIContext>([{ isResponse: false }, { isResponse: true }])(
       "should pass the next depicter %#",
@@ -614,6 +599,21 @@ describe("Open API helpers", () => {
         })
       ).toMatchSnapshot();
     });
+
+    test.each([
+      z.number().transform((num) => () => num),
+      z.number().transform(() => {
+        throw new Error("this should be handled");
+      }),
+    ])("should handle edge cases", (schema) => {
+      expect(
+        depictEffect({
+          schema,
+          ...responseContext,
+          next: makeNext(responseContext),
+        })
+      ).toMatchSnapshot();
+    });
   });
 
   describe("depictPipeline", () => {
diff --git a/tests/unit/zts.spec.ts b/tests/unit/zts.spec.ts
index c41854e3..9bebf64b 100644
--- a/tests/unit/zts.spec.ts
+++ b/tests/unit/zts.spec.ts
@@ -327,4 +327,34 @@ describe("zod-to-ts", () => {
       expect(printNodeTest(zodToTs({ schema }))).toMatchSnapshot();
     });
   });
+
+  describe("z.effect()", () => {
+    describe("transformations", () => {
+      test.each([
+        { isResponse: false, expected: "intact" },
+        { isResponse: true, expected: "transformed" },
+      ])("should produce the schema type $expected", ({ isResponse }) => {
+        const schema = z.number().transform((num) => `${num}`);
+        expect(
+          printNodeTest(zodToTs({ schema, isResponse }))
+        ).toMatchSnapshot();
+      });
+
+      test("should handle unsupported transformation in response", () => {
+        const schema = z.number().transform((num) => () => num);
+        expect(
+          printNodeTest(zodToTs({ schema, isResponse: true }))
+        ).toMatchSnapshot();
+      });
+
+      test("should handle an error within the transformation", () => {
+        const schema = z.number().transform(() => {
+          throw new Error("this should be handled");
+        });
+        expect(
+          printNodeTest(zodToTs({ schema, isResponse: true }))
+        ).toMatchSnapshot();
+      });
+    });
+  });
 });