Repository: fabien0102/ts-to-zod
Pull request #205 (2024-02-19 14:11:21)

First fix for #203
Hey @schiller-manuel, 

As it seems more people are reporting #203, maybe we can merge the first fix (the first working version from #204, not adding the extra files into the validation) to not block anybody. WDYT?

I think there is a bit of work on #204 still to handle some other cases. 

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/205?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:
> Comparison is base [(`4bd5972`)](https://app.codecov.io/gh/fabien0102/ts-to-zod/commit/4bd5972e2855f0e4ae904bcd9012171887e038b9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) 97.17% compared to head [(`6858c65`)](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/205?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) 97.20%.


: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     #205      +/-   ##
==========================================
+ Coverage   97.17%   97.20%   +0.03%     
==========================================
  Files          15       15              
  Lines         814      823       +9     
  Branches      325      334       +9     
==========================================
+ Hits          791      800       +9     
  Misses         23       23              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/205?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/src/utils/fixOptionalAny.ts b/src/utils/fixOptionalAny.ts
index 2131cee..25ef6ed 100644
--- a/src/utils/fixOptionalAny.ts
+++ b/src/utils/fixOptionalAny.ts
@@ -1,9 +1,13 @@
 import ts, { factory as f } from "typescript";
+import { getImportIdentifiers } from "./importHandling";
 
 /**
- * Add optional property to `any` to workaround comparison issue.
+ * Add optional property to `any` or imported types to workaround comparison issue.
+ *
+ * ref:
+ * -> https://github.com/fabien0102/ts-to-zod/issues/140
+ * -> https://github.com/fabien0102/ts-to-zod/issues/203
  *
- * ref: https://github.com/fabien0102/ts-to-zod/issues/140
  */
 export function fixOptionalAny(sourceText: string) {
   const sourceFile = ts.createSourceFile(
@@ -11,7 +15,25 @@ export function fixOptionalAny(sourceText: string) {
     sourceText,
     ts.ScriptTarget.Latest
   );
-  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
+
+  // Extracting imports
+  const importNamesAvailable = new Set<string>();
+  const extractImportIdentifiers = (node: ts.Node) => {
+    if (ts.isImportDeclaration(node) && node.importClause) {
+      const identifiers = getImportIdentifiers(node);
+      identifiers.forEach((i) => importNamesAvailable.add(i));
+    }
+  };
+  ts.forEachChild(sourceFile, extractImportIdentifiers);
+
+  function shouldAddQuestionToken(node: ts.TypeNode) {
+    return (
+      node.kind === ts.SyntaxKind.AnyKeyword ||
+      // Handling type referencing imported types
+      (ts.isTypeReferenceNode(node) &&
+        importNamesAvailable.has(node.typeName.getText(sourceFile)))
+    );
+  }
 
   const markAnyAsOptional: ts.TransformerFactory<ts.SourceFile> = (context) => {
     const visit: ts.Visitor = (node) => {
@@ -42,15 +64,14 @@ export function fixOptionalAny(sourceText: string) {
     return (sourceFile) => ts.visitNode(sourceFile, visit) as ts.SourceFile;
   };
 
+  // Apply transformation
   const outputFile = ts.transform(sourceFile, [markAnyAsOptional]);
 
+  // Printing the transformed file
+  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
   return printer.printFile(outputFile.transformed[0]);
 }
 
-function shouldAddQuestionToken(node: ts.TypeNode) {
-  return node.kind === ts.SyntaxKind.AnyKeyword || ts.isTypeReferenceNode(node);
-}
-
 function createOptionalPropertyNode(node: ts.PropertySignature) {
   return ts.factory.createPropertySignature(
     node.modifiers,


Test patch:
diff --git a/src/core/validateGeneratedTypes.test.ts b/src/core/validateGeneratedTypes.test.ts
index 5fe54b0..25ce9a1 100644
--- a/src/core/validateGeneratedTypes.test.ts
+++ b/src/core/validateGeneratedTypes.test.ts
@@ -97,6 +97,124 @@ describe("validateGeneratedTypes", () => {
     expect(errors).toEqual([]);
   });
 
+  it("should return no error if we reference a non-imported type", () => {
+    const sourceTypes = {
+      sourceText: `
+      export type Villain = {
+        name: string;
+      }
+
+      export type Citizen = {
+        villain: Villain | null;
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+    import { z } from "zod";
+
+    export const villainSchema = z.object({
+      name: z.string()
+    });
+
+    export const citizenSchema = z.object({
+      villain: villainSchema.nullable()
+    });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const integrationTests = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+
+      import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}";
+      import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}";
+
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      function expectType<T>(_: T) {
+        /* noop */
+      }
+
+      export type CitizenInferredType = z.infer<typeof generated.citizenSchema>;
+
+      expectType<CitizenInferredType>({} as spec.Citizen);
+      expectType<spec.Citizen>({} as CitizenInferredType);
+  `,
+      relativePath: "source.integration.ts",
+    };
+
+    const errors = validateGeneratedTypes({
+      sourceTypes,
+      zodSchemas,
+      integrationTests,
+      skipParseJSDoc: false,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
+  it("should return no error if we reference a non-imported type in an Array", () => {
+    const sourceTypes = {
+      sourceText: `
+      export type Villain = {
+        name: string;
+      }
+
+      export type Citizen = {
+        villains: Array<Villain>;
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+    import { z } from "zod";
+
+    export const villainSchema = z.object({
+      name: z.string()
+    });
+
+    export const citizenSchema = z.object({
+      villains: z.array(villainSchema)
+    });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const integrationTests = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+
+      import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}";
+      import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}";
+
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      function expectType<T>(_: T) {
+        /* noop */
+      }
+
+      export type CitizenInferredType = z.infer<typeof generated.citizenSchema>;
+
+      expectType<CitizenInferredType>({} as spec.Citizen);
+      expectType<spec.Citizen>({} as CitizenInferredType);
+  `,
+      relativePath: "source.integration.ts",
+    };
+
+    const errors = validateGeneratedTypes({
+      sourceTypes,
+      zodSchemas,
+      integrationTests,
+      skipParseJSDoc: false,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
   it("should return no error if we use a 'deep' non-optional any", () => {
     const sourceTypes = {
       sourceText: `
@@ -155,7 +273,7 @@ describe("validateGeneratedTypes", () => {
     expect(errors).toEqual([]);
   });
 
-  it("should return no error if we use an external import", () => {
+  it("should return no error if we reference an external import", () => {
     const sourceTypes = {
       sourceText: `
       import { Hero } from "./hero-module.ts"
@@ -209,6 +327,60 @@ describe("validateGeneratedTypes", () => {
     expect(errors).toEqual([]);
   });
 
+  it("should return no error if we reference an array of external imports", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "./hero-module.ts"
+
+      export interface Citizen {
+        heros: Array<Hero>
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+    import { z } from "zod";
+    const heroSchema = z.any();
+
+    export const citizenSchema = z.object({
+      heros: z.array(heroSchema)
+    });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const integrationTests = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+
+      import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}";
+      import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}";
+
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      function expectType<T>(_: T) {
+        /* noop */
+      }
+
+      export type CitizenInferredType = z.infer<typeof generated.citizenSchema>;
+
+      expectType<CitizenInferredType>({} as spec.Citizen);
+      expectType<spec.Citizen>({} as CitizenInferredType);
+  `,
+      relativePath: "source.integration.ts",
+    };
+
+    const errors = validateGeneratedTypes({
+      sourceTypes,
+      zodSchemas,
+      integrationTests,
+      skipParseJSDoc: false,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
   it("should return no error if we use a deep external import", () => {
     const sourceTypes = {
       sourceText: `