Repository: fabien0102/ts-to-zod
Pull request #266 (2024-09-03 17:37:28)

feat: allow to append or override schema via jsdoc tag

codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/266?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.90%. Comparing base [(`113f7d3`)](https://app.codecov.io/gh/fabien0102/ts-to-zod/commit/113f7d34c81299a9e8c91b9890311cedac0aded5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) to head [(`84aa87a`)](https://app.codecov.io/gh/fabien0102/ts-to-zod/commit/84aa87aa9a18444c32f682c33bfa9adc013f06f0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).
> Report is 29 commits behind head on main.

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #266      +/-   ##
==========================================
- Coverage   95.98%   95.90%   -0.09%     
==========================================
  Files          18       18              
  Lines         922      805     -117     
  Branches      368      270      -98     
==========================================
- Hits          885      772     -113     
+ Misses         37       29       -8     
- Partials        0        4       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/266?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).


Patch:
diff --git a/README.md b/README.md
index 5f0c69c..f6d97fd 100644
--- a/README.md
+++ b/README.md
@@ -150,11 +150,13 @@ export const heroContactSchema = z.object({
 
 Other JSDoc tags are available:
 
-| JSDoc keyword          | JSDoc Example            | Description                               | Generated Zod                      |
-| ---------------------- | ------------------------ | ----------------------------------------- | ---------------------------------- |
-| `@description {value}` | `@description Full name` | Sets the description of the property      | `z.string().describe("Full name")` |
-| `@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 keyword          | JSDoc Example            | Description                                                                                                                               | Generated Zod                      |
+|------------------------|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|
+| `@description {value}` | `@description Full name` | Sets the description of the property                                                                                                      | `z.string().describe("Full name")` |
+| `@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()`              |
+| `@schema`              | `@schema .catch('foo')`  | If value starts with a `.`, appends the specified value to the generated schema. Otherwise this value will override the generated schema. | `z.string().catch('foo')`          |
+  |
 
 ## JSDoc tags for elements of `string` and `number` arrays
 
diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts
index 75f5316..b356d54 100644
--- a/src/core/generateZodSchema.ts
+++ b/src/core/generateZodSchema.ts
@@ -62,6 +62,21 @@ type SchemaExtensionClause = {
   omitOrPickKeys?: ts.TypeNode;
 };
 
+interface BuildZodPrimitiveParams {
+  z: string;
+  typeNode: ts.TypeNode;
+  isOptional: boolean;
+  isNullable?: boolean;
+  isPartial?: boolean;
+  isRequired?: boolean;
+  jsDocTags: JSDocTags;
+  customJSDocFormatTypes: CustomJSDocFormatTypes;
+  sourceFile: ts.SourceFile;
+  dependencies: string[];
+  getDependencyName: (identifierName: string) => string;
+  skipParseJSDoc: boolean;
+}
+
 /**
  * Generate zod schema declaration
  *
@@ -288,6 +303,35 @@ function buildZodProperties({
 }
 
 function buildZodPrimitive({
+  jsDocTags,
+  z,
+  ...rest
+}: BuildZodPrimitiveParams):
+  | ts.CallExpression
+  | ts.Identifier
+  | ts.PropertyAccessExpression {
+  const schema = jsDocTags.schema;
+  delete jsDocTags.schema;
+  const generatedSchema = buildZodPrimitiveInternal({ jsDocTags, z, ...rest });
+  // schema not specified? return generated one
+  if (!schema) {
+    return generatedSchema;
+  }
+  // schema starts with dot? append it
+  if (schema.startsWith(".")) {
+    return f.createPropertyAccessExpression(
+      generatedSchema,
+      f.createIdentifier(schema.slice(1))
+    );
+  }
+  // otherwise use provided schema verbatim
+  return f.createPropertyAccessExpression(
+    f.createIdentifier(z),
+    f.createIdentifier(schema)
+  );
+}
+
+function buildZodPrimitiveInternal({
   z,
   typeNode,
   isOptional,
@@ -300,20 +344,10 @@ function buildZodPrimitive({
   dependencies,
   getDependencyName,
   skipParseJSDoc,
-}: {
-  z: string;
-  typeNode: ts.TypeNode;
-  isOptional: boolean;
-  isNullable?: boolean;
-  isPartial?: boolean;
-  isRequired?: boolean;
-  jsDocTags: JSDocTags;
-  customJSDocFormatTypes: CustomJSDocFormatTypes;
-  sourceFile: ts.SourceFile;
-  dependencies: string[];
-  getDependencyName: (identifierName: string) => string;
-  skipParseJSDoc: boolean;
-}): ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression {
+}: BuildZodPrimitiveParams):
+  | ts.CallExpression
+  | ts.Identifier
+  | ts.PropertyAccessExpression {
   const zodProperties = jsDocTagToZodProperties(
     jsDocTags,
     customJSDocFormatTypes,
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index d42f014..029b02b 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -68,6 +68,7 @@ export interface JSDocTagsBase {
    */
   pattern?: string;
   strict?: boolean;
+  schema?: string;
 }
 
 export type ElementJSDocTags = Pick<
@@ -94,6 +95,7 @@ const jsDocTagKeys: Array<keyof JSDocTags> = [
   "maxLength",
   "format",
   "pattern",
+  "schema",
   "elementDescription",
   "elementMinimum",
   "elementMaximum",
@@ -177,6 +179,7 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
             break;
           case "description":
           case "elementDescription":
+          case "schema":
           case "pattern":
           case "elementPattern":
             if (tag.comment) {


Test patch:
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index 1c779d4..5ca0a5f 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -964,6 +964,48 @@ describe("generateZodSchema", () => {
     `);
   });
 
+  it("should append schema based on `schema` tag", () => {
+    const source = `export interface HeroContact {
+      /**
+       * The email of the hero.
+       *
+       * @schema .trim().catch('hello@world.com')
+       */
+      email: string;
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const heroContactSchema = z.object({
+          /**
+           * The email of the hero.
+           *
+           * @schema .trim().catch('hello@world.com')
+           */
+          email: z.string().trim().catch('hello@world.com')
+      });"
+    `);
+  });
+
+  it("should overrride schema based on `schema` tag", () => {
+    const source = `export interface HeroContact {
+      /**
+       * The email of the hero.
+       *
+       * @schema coerce.int()
+       */
+      age: number;
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const heroContactSchema = z.object({
+          /**
+           * The email of the hero.
+           *
+           * @schema coerce.int()
+           */
+          age: z.coerce.int()
+      });"
+    `);
+  });
+
   it("should generate custom error message for `format` tag", () => {
     const source = `export interface HeroContact {
       /**