Repository: fabien0102/ts-to-zod
Pull request #73 (2022-02-25 16:19:22)

feat: support custom zod error message
## Summary

Add the possibility to add custom error message to zod validation.

Example:

```ts
export interface HeroContact {
      /**
       * The email of the hero.
       *
       * @format email should be an email
       */
      email: string;
    
      /**
       * The name of the hero.
       *
       * @minLength 2, should be more than 2
       * @maxLength 50 should be less than 50
       */
      name: string;
    
      /**
       * The age of the hero
       * 
       * @minimum 0 you are too young
       * @maximum 500, "you are too old"
       */
      age: number;
    }
```

will produce

```ts
export const heroContactSchema = z.object({
          /**
           * The email of the hero.
           *
           * @format email should be an email
           */
          email: z.string().email("should be an email"),
          /**
           * The name of the hero.
           *
           * @minLength 2, should be more than 2
           * @maxLength 50 should be less than 50
           */
          name: z.string().min(2, "should be more than 2").max(50, "should be less than 50"),
          /**
           * The age of the hero
           *
           * @minimum 0 you are too young
           * @maximum 500, "you are too old"
           */
          age: z.number().min(0, "you are too young").max(500, "you are too old")
      });
```

## Related issue

#55 

## Notes

- The `pattern` is not supported, it's actually really hard to differenciate the pattern and the error message.
- I didn't add `required` keyword also, this is touching a bit more files and I'm not totally sure about the pattern

In the context of an express middleware, as in the original issue, I think (and this is totally personnal) I would go on a global error management logic (https://github.com/colinhacks/zod/blob/master/ERROR_HANDLING.md) rather than specify everything manually in a JSDoc tag. So the error messages are standard and you can just tweak the edge cases.


Patch:
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index 4283f61..027264c 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -14,16 +14,21 @@ const formats = [
   // "date-time" as const,
 ];
 
+type TagWithError<T> = {
+  value: T;
+  errorMessage?: string;
+};
+
 /**
  * JSDoc special tags that can be converted in zod flags.
  */
 export interface JSDocTags {
-  minimum?: number;
-  maximum?: number;
+  minimum?: TagWithError<number>;
+  maximum?: TagWithError<number>;
   default?: number | string | boolean;
-  minLength?: number;
-  maxLength?: number;
-  format?: typeof formats[-1];
+  minLength?: TagWithError<number>;
+  maxLength?: TagWithError<number>;
+  format?: TagWithError<typeof formats[-1]>;
   pattern?: string;
 }
 
@@ -52,10 +57,32 @@ function isJSDocTagKey(tagName: string): tagName is keyof JSDocTags {
  */
 function isSupportedFormat(
   format = ""
-): format is Required<JSDocTags>["format"] {
+): format is Required<JSDocTags>["format"]["value"] {
   return (formats as string[]).includes(format);
 }
 
+/**
+ * Parse js doc comment.
+ *
+ * @example
+ * parseJsDocComment("email should be an email");
+ * // {value: "email", errorMessage: "should be an email"}
+ *
+ * @param comment
+ */
+function parseJsDocComment(
+  comment: string
+): { value: string; errorMessage?: string } {
+  const [value, ...rest] = comment.split(" ");
+  const errorMessage =
+    rest.join(" ").replace(/(^["']|["']$)/g, "") || undefined;
+
+  return {
+    value: value.replace(",", "").replace(/(^["']|["']$)/g, ""),
+    errorMessage,
+  };
+}
+
 /**
  * Return parsed JSTags.
  *
@@ -71,13 +98,15 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
       (doc.tags || []).forEach((tag) => {
         const tagName = tag.tagName.escapedText.toString();
         if (!isJSDocTagKey(tagName) || typeof tag.comment !== "string") return;
+        const { value, errorMessage } = parseJsDocComment(tag.comment);
+
         switch (tagName) {
           case "minimum":
           case "maximum":
           case "minLength":
           case "maxLength":
-            if (tag.comment && !Number.isNaN(parseInt(tag.comment))) {
-              jsDocTags[tagName] = parseInt(tag.comment);
+            if (value && !Number.isNaN(parseInt(value))) {
+              jsDocTags[tagName] = { value: parseInt(value), errorMessage };
             }
             break;
           case "pattern":
@@ -86,8 +115,8 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
             }
             break;
           case "format":
-            if (isSupportedFormat(tag.comment)) {
-              jsDocTags[tagName] = tag.comment;
+            if (isSupportedFormat(value)) {
+              jsDocTags[tagName] = { value, errorMessage };
             }
             break;
           case "default":
@@ -146,30 +175,45 @@ export function jsDocTagToZodProperties(
   if (jsDocTags.minimum !== undefined) {
     zodProperties.push({
       identifier: "min",
-      expressions: [f.createNumericLiteral(jsDocTags.minimum)],
+      expressions: withErrorMessage(
+        f.createNumericLiteral(jsDocTags.minimum.value),
+        jsDocTags.minimum.errorMessage
+      ),
     });
   }
   if (jsDocTags.maximum !== undefined) {
     zodProperties.push({
       identifier: "max",
-      expressions: [f.createNumericLiteral(jsDocTags.maximum)],
+      expressions: withErrorMessage(
+        f.createNumericLiteral(jsDocTags.maximum.value),
+        jsDocTags.maximum.errorMessage
+      ),
     });
   }
   if (jsDocTags.minLength !== undefined) {
     zodProperties.push({
       identifier: "min",
-      expressions: [f.createNumericLiteral(jsDocTags.minLength)],
+      expressions: withErrorMessage(
+        f.createNumericLiteral(jsDocTags.minLength.value),
+        jsDocTags.minLength.errorMessage
+      ),
     });
   }
   if (jsDocTags.maxLength !== undefined) {
     zodProperties.push({
       identifier: "max",
-      expressions: [f.createNumericLiteral(jsDocTags.maxLength)],
+      expressions: withErrorMessage(
+        f.createNumericLiteral(jsDocTags.maxLength.value),
+        jsDocTags.maxLength.errorMessage
+      ),
     });
   }
   if (jsDocTags.format) {
     zodProperties.push({
-      identifier: jsDocTags.format,
+      identifier: jsDocTags.format.value,
+      expressions: jsDocTags.format.errorMessage
+        ? [f.createStringLiteral(jsDocTags.format.errorMessage)]
+        : undefined,
     });
   }
   if (jsDocTags.pattern) {
@@ -214,3 +258,10 @@ export function jsDocTagToZodProperties(
 
   return zodProperties;
 }
+
+function withErrorMessage(expression: ts.Expression, errorMessage?: string) {
+  if (errorMessage) {
+    return [expression, f.createStringLiteral(errorMessage)];
+  }
+  return [expression];
+}


Test patch:
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index c409290..b2e71c4 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -669,11 +669,109 @@ describe("generateZodSchema", () => {
     `);
   });
 
+  it("should generate custom error message for `format` tag", () => {
+    const source = `export interface HeroContact {
+      /**
+       * The email of the hero.
+       *
+       * @format email Should be an email
+       */
+      heroEmail: string;
+
+      /**
+       * The email of the enemy.
+       *
+       * @format email, "Should be an email"
+       */
+      enemyEmail: string;
+      
+      /**
+       * The email of the superman.
+       *
+       * @format email "Should be an email"
+       */
+      supermanEmail: string;
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const heroContactSchema = z.object({
+          /**
+           * The email of the hero.
+           *
+           * @format email Should be an email
+           */
+          heroEmail: z.string().email(\\"Should be an email\\"),
+          /**
+           * The email of the enemy.
+           *
+           * @format email, \\"Should be an email\\"
+           */
+          enemyEmail: z.string().email(\\"Should be an email\\"),
+          /**
+           * The email of the superman.
+           *
+           * @format email \\"Should be an email\\"
+           */
+          supermanEmail: z.string().email(\\"Should be an email\\")
+      });"
+    `);
+  });
+
+  it("should generate custom error message based on jsdoc tags", () => {
+    const source = `export interface HeroContact {
+      /**
+       * The email of the hero.
+       *
+       * @format email should be an email
+       */
+      email: string;
+    
+      /**
+       * The name of the hero.
+       *
+       * @minLength 2, should be more than 2
+       * @maxLength 50 should be less than 50
+       */
+      name: string;
+    
+      /**
+       * The age of the hero
+       * 
+       * @minimum 0 you are too young
+       * @maximum 500, "you are too old"
+       */
+      age: number;
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const heroContactSchema = z.object({
+          /**
+           * The email of the hero.
+           *
+           * @format email should be an email
+           */
+          email: z.string().email(\\"should be an email\\"),
+          /**
+           * The name of the hero.
+           *
+           * @minLength 2, should be more than 2
+           * @maxLength 50 should be less than 50
+           */
+          name: z.string().min(2, \\"should be more than 2\\").max(50, \\"should be less than 50\\"),
+          /**
+           * The age of the hero
+           *
+           * @minimum 0 you are too young
+           * @maximum 500, \\"you are too old\\"
+           */
+          age: z.number().min(0, \\"you are too young\\").max(500, \\"you are too old\\")
+      });"
+    `);
+  });
+
   it("should generate validator on top-level types", () => {
     const source = `/**
     * @minLength 1
     */
-   export type NonEmptyString = string;`;
+    export type NonEmptyString = string;`;
 
     expect(generate(source)).toMatchInlineSnapshot(`
       "/**