Repository: fabien0102/ts-to-zod
Pull request #180 (2023-11-16 18:31:24)

feat: add JSDoc tags for `string` and `number` array elements
I wanted to be able to add validators for plain `string[]` and `number[]`.
With this PR I propose the following additional JSDoc tags:
```typescript
 @elementMinimum
 @elementMaximum
 @elementMinLength
 @elementMaxLength
 @elementPattern
 @elementFormat
```
For details please see the tests and README.md


Patch:
diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml
index bf49b33..6ba5438 100644
--- a/.github/workflows/release-please.yaml
+++ b/.github/workflows/release-please.yaml
@@ -19,7 +19,7 @@ jobs:
         if: ${{ steps.release.outputs.release_created }}
       - uses: actions/setup-node@v1
         with:
-          node-version: 12
+          node-version: 20
           registry-url: 'https://registry.npmjs.org'
         if: ${{ steps.release.outputs.release_created }}
       - run: yarn
diff --git a/README.md b/README.md
index 7945416..5d15bdc 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,64 @@ Other JSDoc tags are available:
 | `@default {value}` | `@default 42` | Sets a default value for the property     | `z.number().default(42)` |
 | `@strict`          | `@strict`     | Adds the `strict()` modifier to an object | `z.object().strict()`    |
 
+## JSDoc tags for elements of `string` and `number` arrays
+
+Elements of `string` and `number` arrays can be validated using the following JSDoc tags (for details see above).
+
+| JSDoc keyword                          |
+| ---------------------------------------|
+| `@elementMinimum {number} [err_msg]`   |
+| `@elementMaximum {number} [err_msg]`   |
+| `@elementMinLength {number} [err_msg]` |
+| `@elementMaxLength {number} [err_msg]` |
+| `@elementFormat {FormatType} [err_msg]`|
+| `@elementPattern {regex}`              |
+
+Example:
+
+```ts
+// source.ts
+export interface EnemyContact {
+
+  /**
+   * The names of the enemy.
+   * 
+   * @elementMinLength 5
+   * @elementMaxLength 10
+   * @minLength 2
+   * @maxLength 50
+   */
+  names: string[];
+
+  /**
+   * The phone numbers of the enemy.
+   *
+   * @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
+   */
+  phoneNumbers: string[];
+}
+
+// output.ts
+export const enemyContactSchema = z.object({
+  /**
+   * The names of the enemy.
+   *
+   * @elementMinLength 5
+   * @elementMaxLength 10
+   * @minLength 2
+   * @maxLength 50
+   */
+  name: z.array(z.string().min(5).max(10)).min(2).max(50),
+
+  /**
+   * The phone numbers of the enemy.
+   *
+   * @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
+   */
+  phoneNumbers: z.array(z.string().regex(/^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$/)),
+});
+```
+
 ## Advanced configuration
 
 If you want to customize the schema name or restrict the exported schemas, you can do this by adding a `ts-to-zod.config.js` at the root of your project.
diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts
index 4df0f0e..6de0483 100644
--- a/src/core/generateZodSchema.ts
+++ b/src/core/generateZodSchema.ts
@@ -289,7 +289,7 @@ function buildZodPrimitive({
         typeNode: f.createArrayTypeNode(typeNode.typeArguments[0]),
         isOptional,
         isNullable,
-        jsDocTags: {},
+        jsDocTags,
         sourceFile,
         dependencies,
         getDependencyName,
@@ -696,7 +696,14 @@ function buildZodPrimitive({
           z,
           typeNode: typeNode.elementType,
           isOptional: false,
-          jsDocTags: {},
+          jsDocTags: {
+            minimum: jsDocTags.elementMinimum,
+            maximum: jsDocTags.elementMaximum,
+            minLength: jsDocTags.elementMinLength,
+            maxLength: jsDocTags.elementMaxLength,
+            format: jsDocTags.elementFormat,
+            pattern: jsDocTags.elementPattern,
+          },
           sourceFile,
           dependencies,
           getDependencyName,
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index c112757..2643a9c 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -52,7 +52,7 @@ type TagWithError<T> = {
 /**
  * JSDoc special tags that can be converted in zod flags.
  */
-export interface JSDocTags {
+export interface JSDocTagsBase {
   minimum?: TagWithError<number>;
   maximum?: TagWithError<number>;
   default?: number | string | boolean;
@@ -67,6 +67,15 @@ export interface JSDocTags {
   strict?: boolean;
 }
 
+export type ElementJSDocTags = Pick<
+  JSDocTagsBase,
+  "minimum" | "maximum" | "minLength" | "maxLength" | "pattern" | "format"
+>;
+
+export type JSDocTags = JSDocTagsBase & {
+  [K in keyof ElementJSDocTags as `element${Capitalize<K>}`]: ElementJSDocTags[K];
+};
+
 const jsDocTagKeys: Array<keyof JSDocTags> = [
   "minimum",
   "maximum",
@@ -75,6 +84,12 @@ const jsDocTagKeys: Array<keyof JSDocTags> = [
   "maxLength",
   "format",
   "pattern",
+  "elementMinimum",
+  "elementMaximum",
+  "elementMinLength",
+  "elementMaxLength",
+  "elementPattern",
+  "elementFormat",
 ];
 
 /**
@@ -141,16 +156,22 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
           case "maximum":
           case "minLength":
           case "maxLength":
+          case "elementMinLength":
+          case "elementMaxLength":
+          case "elementMinimum":
+          case "elementMaximum":
             if (value && !Number.isNaN(parseInt(value))) {
               jsDocTags[tagName] = { value: parseInt(value), errorMessage };
             }
             break;
           case "pattern":
+          case "elementPattern":
             if (tag.comment) {
               jsDocTags[tagName] = tag.comment;
             }
             break;
           case "format":
+          case "elementFormat":
             jsDocTags[tagName] = { value, errorMessage };
             break;
           case "default":
diff --git a/src/utils/traverseTypes.ts b/src/utils/traverseTypes.ts
index 3afb4ae..b74b3a2 100644
--- a/src/utils/traverseTypes.ts
+++ b/src/utils/traverseTypes.ts
@@ -84,7 +84,13 @@ export function getExtractedTypeNames(
         extensionTypes.forEach((extensionTypeNode) => {
           const typeName = extensionTypeNode.expression.getText(sourceFile);
 
-          referenceTypeNames.add(typeName);
+          if (extensionTypeNode.typeArguments) {
+            extensionTypeNode.typeArguments.forEach((t) => handleTypeNode(t));
+          }
+
+          if (typeScriptHelper.indexOf(typeName) === -1) {
+            referenceTypeNames.add(typeName);
+          }
         });
       });
     }


Test patch:
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index 2b335c9..7b40a25 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -728,6 +728,33 @@ describe("generateZodSchema", () => {
        * @format ip
        */
       ip: string;
+
+      /**
+       * The hero's known IPs
+       *
+       * @elementFormat ip
+       * @maxLength 5
+       */
+      knownIps: Array<string>;
+
+      /**
+       * The hero's last ping times
+       *
+       * @elementMinimum 0
+       * @elementMaximum 100
+       * @minLength 1
+       * @maxLength 10
+       */
+      pingTimes: number[];
+
+      /**
+       * The hero's blocked phone numbers.
+       *
+       * @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
+       * @minLength 56
+       * @maxLength 123
+       */
+      blockedPhoneNumbers: string[];
     }`;
     expect(generate(source)).toMatchInlineSnapshot(`
       "export const heroContactSchema = z.object({
@@ -786,7 +813,31 @@ describe("generateZodSchema", () => {
            *
            * @format ip
            */
-          ip: z.string().ip()
+          ip: z.string().ip(),
+          /**
+           * The hero's known IPs
+           *
+           * @elementFormat ip
+           * @maxLength 5
+           */
+          knownIps: z.array(z.string().ip()).max(5),
+          /**
+           * The hero's last ping times
+           *
+           * @elementMinimum 0
+           * @elementMaximum 100
+           * @minLength 1
+           * @maxLength 10
+           */
+          pingTimes: z.array(z.number().min(0).max(100)).min(1).max(10),
+          /**
+           * The hero's blocked phone numbers.
+           *
+           * @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
+           * @minLength 56
+           * @maxLength 123
+           */
+          blockedPhoneNumbers: z.array(z.string().regex(/^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$/)).min(56).max(123)
       });"
     `);
   });
diff --git a/src/utils/traverseTypes.test.ts b/src/utils/traverseTypes.test.ts
index 565ea3f..7b91b5f 100644
--- a/src/utils/traverseTypes.test.ts
+++ b/src/utils/traverseTypes.test.ts
@@ -63,6 +63,46 @@ describe("traverseTypes", () => {
       expect(result).toEqual(["Superhero", "Person", "Person2"]);
     });
 
+    it("should extract type referenced in extend clause with Pick helper", () => {
+      const source = `
+            export interface Superhero extends Pick<Person, "name"> {
+                id: number,
+            }`;
+
+      const result = extractNames(source);
+      expect(result).toEqual(["Superhero", "Person"]);
+    });
+
+    it("should extract type referenced in extend clause with Omit helper", () => {
+      const source = `
+            export interface Superhero extends Omit<Person, "name"> {
+                id: number,
+            }`;
+
+      const result = extractNames(source);
+      expect(result).toEqual(["Superhero", "Person"]);
+    });
+
+    it("should extract type referenced in extend clause with Partial helper", () => {
+      const source = `
+            export interface Superhero extends Partial<Person, "name"> {
+                id: number,
+            }`;
+
+      const result = extractNames(source);
+      expect(result).toEqual(["Superhero", "Person"]);
+    });
+
+    it("should extract type referenced in extend clause with Record helper", () => {
+      const source = `
+            export interface Superhero extends Record<string, Person2> {
+                id: number,
+            }`;
+
+      const result = extractNames(source);
+      expect(result).toEqual(["Superhero", "Person2"]);
+    });
+
     it("should extract type referenced in property as array", () => {
       const source = `
             export interface Superhero {