Repository: fabien0102/ts-to-zod (ts)

Issue:
Mark types for schema generation
## Feature description

I think it would be useful to have a way to mark only certain types for zod schema generation.

I have many cases of files that have several exported types but only some types actually need zod schemas. The current workaround for this is to split out the types that need schema generation into a separate file nearby but at the sacrifice of code colocation.

I imagine this could be done via some addition JSDoc directive on the type declaration, as those are already parsed and used for adding refinements.



Pull request description:
feat: add JSDocTag filter
# Why

Add `jsDocTagFilter` option to cherry-pick what type to export.

Closed #30 


Patch:
diff --git a/README.md b/README.md
index e3ef6ec..3cf65e1 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,53 @@ If you want to customized the schema name or restrict the exported schemas, you
 
 Just run `yarn ts-to-zod --init` and you will have a ready to use configuration file (with a bit of typesafety).
 
+You have two ways to restrict the scope of ts-to-zod:
+
+- `nameFilter` will filter by interface/type name
+- `jsDocTagFilter` will filter on jsDocTag
+
+Example:
+
+```ts
+// ts-to-zod.config.js
+/**
+ * ts-to-zod configuration.
+ *
+ * @type {import("./src/config").TsToZodConfig}
+ */
+module.exports = [
+  {
+    name: "example",
+    input: "example/heros.ts",
+    output: "example/heros.zod.ts",
+    jsDocTagFilter: (tags) => tags.map(tag => tag.name).includes("toExtract")) // <= rule here
+  },
+];
+
+// example/heros.ts
+/**
+ * Will not be part of `example/heros.zod.ts`
+ */
+export interface Enemy {
+  name: string;
+  powers: string[];
+  inPrison: boolean;
+}
+
+/**
+ * Will be part of `example/heros.zod.ts`
+ * @toExtract
+ */
+export interface Superman {
+  name: "superman" | "clark kent" | "kal-l";
+  enemies: Record<string, Enemy>;
+  age: number;
+  underKryptonite?: boolean;
+}
+```
+
+/!\ Please note: if your exported interface/type have a reference to a non-exported interface/type, ts-to-zod will not be able to generate anything (a circular dependency will be report due to the missing reference).
+
 ## Limitation
 
 Since we are generating Zod schemas, we are limited by what Zod actually supports:
diff --git a/src/config.ts b/src/config.ts
index f61085a..7d53b34 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,5 +1,22 @@
+export interface SimplifiedJSDocTag {
+  /**
+   * Name of the tag
+   *
+   * @ref tag.tagName.escapedText.toString()
+   */
+  name: string;
+
+  /**
+   * Value of the tag
+   *
+   * @ref tag.comment
+   */
+  value?: string;
+}
+
 export type GetSchemaName = (identifier: string) => string;
 export type NameFilter = (name: string) => boolean;
+export type JSDocTagFilter = (tags: SimplifiedJSDocTag[]) => boolean;
 
 export type Config = {
   /**
@@ -23,10 +40,15 @@ export type Config = {
   maxRun?: number;
 
   /**
-   * Filter function on type/interface name.
+   * Filter on type/interface name.
    */
   nameFilter?: NameFilter;
 
+  /**
+   * Filter on JSDocTag.
+   */
+  jsDocTagFilter?: JSDocTagFilter;
+
   /**
    * Schema name generator.
    */
diff --git a/src/config.zod.ts b/src/config.zod.ts
index 1ed6ef9..5c4e090 100644
--- a/src/config.zod.ts
+++ b/src/config.zod.ts
@@ -1,6 +1,11 @@
 // Generated by ts-to-zod
 import { z } from "zod";
 
+export const simplifiedJSDocTagSchema = z.object({
+  name: z.string(),
+  value: z.string().optional(),
+});
+
 export const getSchemaNameSchema = z
   .function()
   .args(z.string())
@@ -11,12 +16,18 @@ export const nameFilterSchema = z
   .args(z.string())
   .returns(z.boolean());
 
+export const jSDocTagFilterSchema = z
+  .function()
+  .args(z.array(simplifiedJSDocTagSchema))
+  .returns(z.boolean());
+
 export const configSchema = z.object({
   input: z.string(),
   output: z.string(),
   skipValidation: z.boolean().optional(),
   maxRun: z.number().optional(),
   nameFilter: nameFilterSchema.optional(),
+  jsDocTagFilter: jSDocTagFilterSchema.optional(),
   getSchemaName: getSchemaNameSchema.optional(),
   keepComments: z.boolean().optional().default(false),
   skipParseJSDoc: z.boolean().optional().default(false),
diff --git a/src/core/generate.ts b/src/core/generate.ts
index 5692b03..2776cae 100644
--- a/src/core/generate.ts
+++ b/src/core/generate.ts
@@ -1,6 +1,9 @@
 import { camel } from "case";
+import { getJsDoc } from "tsutils";
 import ts from "typescript";
 import { resolveModules } from "../utils/resolveModules";
+import { JSDocTagFilter, NameFilter } from "../config";
+import { getSimplifiedJsDocTags } from "../utils/getSimplifiedJsDocTags";
 import { generateIntegrationTests } from "./generateIntegrationTests";
 import { generateZodInferredType } from "./generateZodInferredType";
 import { generateZodSchemaVariableStatement } from "./generateZodSchema";
@@ -18,9 +21,14 @@ export interface GenerateProps {
   maxRun?: number;
 
   /**
-   * Filter function on type/interface name.
+   * Filter on type/interface name.
    */
-  nameFilter?: (name: string) => boolean;
+  nameFilter?: NameFilter;
+
+  /**
+   * Filter on JSDocTag.
+   */
+  jsDocTagFilter?: JSDocTagFilter;
 
   /**
    * Schema name generator.
@@ -50,6 +58,7 @@ export function generate({
   sourceText,
   maxRun = 10,
   nameFilter = () => true,
+  jsDocTagFilter = () => true,
   getSchemaName = (id) => camel(id) + "Schema",
   keepComments = false,
   skipParseJSDoc = false,
@@ -68,9 +77,11 @@ export function generate({
       ts.isTypeAliasDeclaration(node) ||
       ts.isEnumDeclaration(node)
     ) {
-      if (nameFilter(node.name.text)) {
-        nodes.push(node);
-      }
+      const jsDoc = getJsDoc(node, sourceFile);
+      const tags = getSimplifiedJsDocTags(jsDoc);
+      if (!jsDocTagFilter(tags)) return;
+      if (!nameFilter(node.name.text)) return;
+      nodes.push(node);
     }
   };
   ts.forEachChild(sourceFile, visitor);
diff --git a/src/utils/getSimplifiedJsDocTags.ts b/src/utils/getSimplifiedJsDocTags.ts
new file mode 100644
index 0000000..dcd9c7e
--- /dev/null
+++ b/src/utils/getSimplifiedJsDocTags.ts
@@ -0,0 +1,22 @@
+import ts from "typescript";
+import { SimplifiedJSDocTag } from "../config";
+
+/**
+ * Get a simplified version of a node JSDocTags.
+ *
+ * @param jsDocs
+ */
+export function getSimplifiedJsDocTags(
+  jsDocs: ts.JSDoc[]
+): SimplifiedJSDocTag[] {
+  const tags: SimplifiedJSDocTag[] = [];
+  jsDocs.forEach((jsDoc) => {
+    (jsDoc.tags || []).forEach((tag) => {
+      const name = tag.tagName.escapedText.toString();
+      const value = typeof tag.comment === "string" ? tag.comment : undefined;
+      tags.push({ name, value });
+    });
+  });
+
+  return tags;
+}


Test patch:
diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts
index 2ce3361..d3195d8 100644
--- a/src/core/generate.test.ts
+++ b/src/core/generate.test.ts
@@ -247,6 +247,57 @@ describe("generate", () => {
     });
   });
 
+  describe("with jsdocTags filter", () => {
+    it("should generate only types with @zod", () => {
+      const sourceText = `
+      /**
+       * @zod
+       **/
+      export type Name = "superman" | "clark kent" | "kal-l";
+
+      /**
+       * @nop
+       */
+      export type BadassSuperman = Omit<Superman, "underKryptonite">;
+
+      /**
+       * Only this interface should be generated
+       * 
+       * @zod
+       */
+      export interface Superman {
+        name: Name;
+        age: number;
+        underKryptonite?: boolean;
+        /**
+         * @format email
+         **/
+        email: string;
+      }
+      `;
+
+      const { getZodSchemasFile } = generate({
+        sourceText,
+        jsDocTagFilter: (tags) => tags.map((tag) => tag.name).includes("zod"),
+      });
+
+      expect(getZodSchemasFile("./source")).toMatchInlineSnapshot(`
+        "// Generated by ts-to-zod
+        import { z } from \\"zod\\";
+
+        export const nameSchema = z.union([z.literal(\\"superman\\"), z.literal(\\"clark kent\\"), z.literal(\\"kal-l\\")]);
+
+        export const supermanSchema = z.object({
+            name: nameSchema,
+            age: z.number(),
+            underKryptonite: z.boolean().optional(),
+            email: z.string().email()
+        });
+        "
+      `);
+    });
+  });
+
   describe("with non-exported types", () => {
     it("should generate tests for exported schemas", () => {
       const sourceText = `