Repository: fabien0102/ts-to-zod
Pull request #204 (2024-02-15 21:33:06)

[Fix] Mark only imported types as optional in validation
# Why

Following [last release](https://github.com/fabien0102/ts-to-zod/releases/tag/v3.7.0) which added import handling, all type reference nodes were marked as optional in the validation step while this was only needed for imported type reference (which are inferred as `any` by Typescript, generated the bug fixed in https://github.com/fabien0102/ts-to-zod/issues/140)

Fixes https://github.com/fabien0102/ts-to-zod/issues/203


tvillaren: @schiller-manuel: this should fix #203 
What do you think?
schiller-manuel: can't we import locally referenced files in the test so that types imported from are not converted to `any`?
e.g. the `Person` interface in the example?
tvillaren: > can't we import locally referenced files in the test so that types imported from there are not converted to any?

I'm not sure to follow: 

```ts
 export type Villain = {
  name: string;
}
export type Citizen = {
  villain: Villain | null;
};
```

is converted to:
```ts
// 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()
});
```

and nothing is inferred from `any` here 
👉 that is the current issue (in the validation, we handle any referenced types, even non-imported ones, as `any`

With regards to imported types, there are converted to `any` by `ts-to-zod` as a choice in my previous PR because we cannot transform all the imports to `zod`.

Are you suggesting to add them to the Virtual File System created during the validation step? 
I remember fiddling with that in vain a while ago when I had the validation issues with imports (https://github.com/fabien0102/ts-to-zod/issues/186)
schiller-manuel: > Are you suggesting to add them to the Virtual File System created during the validation step?

yes exactly, sorry for my unclear formulation.
your fix is good, but it would be even better if during validation the other files would be imported using the VFS.
tvillaren: As we're transforming 

```ts
import { Hero } from "./hero-module.ts"

export interface Citizen {
  hero: Hero
};
```

into 

```ts
import { z } from "zod";
const heroSchema = z.any();

export const citizenSchema = z.object({
  hero: heroSchema
});
```

we would still have an `any` type on the Zod-inferred one, which will be inferred as optional (not a new issue, a known bug as exposed in https://github.com/fabien0102/ts-to-zod/issues/140) 

Even if, in the test, we add `hero-module.ts` to the VFS, it doesn't work (I had a few tries in the context of my last PR, see 954bbcfb6d1796f20122930d3320a05a1b93b344 that passed only because we were using `class` and wouldn't with `type`)

Maybe you have a different suggestion that I'm not seeing?

I understand my fix is not ideal: this whole `fixOptionalAny` is not TBH 😅 but is needed by #140 
An alternative would be to resolve the Symbol linked to each TypeReference node in the `fixOptionalAny` to check if it is imported or not in the file. 



schiller-manuel: I thought we could distinguish between 

1. types that are imported from files which ARE part of the ts-to-zod config and thus create schemas for them and
2. types that are imported from files which are NOT part of the ts-to-zod config and thus we treat them with `z.any()`.

for 1. we could add those files into the VFS, for 2. we would no.
  
tvillaren: In that case, we would still need a way to mark those from 2 as optional during the validation, due to the bug mentioned in #140
schiller-manuel: > In that case, we would still need a way to mark those from 2 as optional during the validation, due to the bug mentioned in #140

yes.

do you think we gain anything / much in terms of typesafety during validation from this?
if not, I will just merge this PR as is.

tvillaren: It's probably better yes, I'll have a look this afternoon (CET)
tvillaren: I added the extraFiles parameter in this commit: https://github.com/fabien0102/ts-to-zod/commit/4c4159760fcd6f16fe7bdbe67fc4010f29f9c234 but the test doesn't pass, if you have any idea.

I'm looking at the "_should return no error if we reference a zod import_" test and I get:
```
 "'Citizen' is not compatible with 'Citizen':
    + Argument of type '{ [x: string]: any; hero?: any; }' is not assignable to parameter of type 'Citizen'.
    +   Property 'hero' is optional in type '{ [x: string]: any; hero?: any; }' but required in type 'Citizen'.",
```

and type output in the console is

```ts
type CitizenInferredType = {
    [x: string]: any;
    hero?: any;
}
```

It seems that the zod schema is not handled well by the VFS. 
Do you see anything?
schiller-manuel: The `zod` import was missing in zHero.ts, test passes if I add is like this: 
```diff
diff --git a/src/core/validateGeneratedTypes.test.ts b/src/core/validateGeneratedTypes.test.ts
index 5f62316..5068c88 100644
--- a/src/core/validateGeneratedTypes.test.ts
+++ b/src/core/validateGeneratedTypes.test.ts
@@ -411,7 +411,8 @@ describe("validateGeneratedTypes", () => {
         relativePath: "hero-module.ts",
       },
       {
-        sourceText: `export const heroSchema = z.object({ name: z.string() })`,
+        sourceText: `import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
         relativePath: "zHero.ts",
       },
     ];
```
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/204?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 [(`65ae74f`)](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/204?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) 97.23%.


: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     #204      +/-   ##
==========================================
+ Coverage   97.17%   97.23%   +0.05%     
==========================================
  Files          15       15              
  Lines         814      831      +17     
  Branches      325      340      +15     
==========================================
+ Hits          791      808      +17     
  Misses         23       23              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/204?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).

schiller-manuel: @tvillaren let me know when this is ready to review
tvillaren: @schiller-manuel 
Handling relative paths with parent directory accessors `../` in the VFS is a bit tricky, particularly when the added file references imports from the `node_modules` (which are at the root of the VFS, so if we add files to the parent directory of the root, well, it doesn't find the import...).

My solution for now is to add fake folders in the VFS based on the maximum depth we're navigating, but I'm not handling all cases yet...
Will get there eventually 😉 
schiller-manuel: 
> My solution for now is to add fake folders in the VFS based on the maximum depth we're navigating

sounds ... hacky 🙃
can you explain in detail please why these relative imports are problematic?
I thought we would "simply" add the existing files into the VFS and that's it. what am I missing?


tvillaren: Let's imagine you run `ts-to-zod` in `/Users/me/ts-to-zod`

This `pwd` becomes the root for the VFS. 
That's were the `node_modules` folder will be set, as well as the source / schema / integration files.

If you add `../zHero.ts` as extra files with content 
```ts
import { z } from "zod";
export const heroSchema = z.object({ name: z.string() })`,
```
without a change, it seems to me that the VFS folder would look like:

```
- Users
   - me
      - zHero.ts
      - ts-to-zod
         - node_modules
         - source.ts
         - source.zod.ts
         - source.integration.ts
```

so the import of `zod` in `zHero.ts` doesn't work.

If we add extra folders, based on the maximum depth, we get: 
```
- Users
   - me
      - ts-to-zod
         - node_modules
         - zHero.ts
         - folder
            - source.ts
            - source.zod.ts
            - source.integration.ts
```
which works well.

Yes, it does sound hacky. 
I feel this whole issue with handling `any` is hacky by nature...

Right now, I managed to get the tests to pass but not yet the `yarn gen:example` (we need to pass the right content to `extraFiles` from the `cli.ts`)
tvillaren: OK, the last version I pushed should cover the case of #207 (which I also added in the example provided through `yarn run gen:example`)

Right now, we're passing all the files from the `ts-to-zod.config.js` file to the validation, which is too much in most cases (as usually, not all files are imported in most cases). So this can be optimized. 

Also, the CI passes even when there are errors in the `test:ci` step (if we had looked at the details of the [last CI](https://github.com/fabien0102/ts-to-zod/actions/runs/7805857022/job/21290968361) run for my initial PR adding imports, we would have seen the `gen:example` fail.


schiller-manuel: sidenote: exiting with code 1 on error might be a breaking change ... so we should increase to v4 maybe?


schiller-manuel: Full quote since there were messages in between:

> Let's imagine you run `ts-to-zod` in `/Users/me/ts-to-zod`
> 
> This `pwd` becomes the root for the VFS. That's were the `node_modules` folder will be set, as well as the source / schema / integration files.
> 
> If you add `../zHero.ts` as extra files with content
> 
> ```ts
> import { z } from "zod";
> export const heroSchema = z.object({ name: z.string() })`,
> ```
> 
> without a change, it seems to me that the VFS folder would look like:
> 
> ```
> - Users
>    - me
>       - zHero.ts
>       - ts-to-zod
>          - node_modules
>          - source.ts
>          - source.zod.ts
>          - source.integration.ts
> ```
> 
> so the import of `zod` in `zHero.ts` doesn't work.
> 
> If we add extra folders, based on the maximum depth, we get:
> 
> ```
> - Users
>    - me
>       - ts-to-zod
>          - node_modules
>          - zHero.ts
>          - folder
>             - source.ts
>             - source.zod.ts
>             - source.integration.ts
> ```
> 
> which works well.
> 
> Yes, it does sound hacky. I feel this whole issue with handling `any` is hacky by nature...
> 
> Right now, I managed to get the tests to pass but not yet the `yarn gen:example` (we need to pass the right content to `extraFiles` from the `cli.ts`)



would it help if the source.ts file was placed at the location of the input file in the VFS? would that result in correct relative links?


 
tvillaren: > sidenote: exiting with code 1 on error might be a breaking change ... so we should increase to v4 maybe?

Indeed, maybe we should change this in another PR, as this PR is meant to fix a bug :) 
I'll revert
tvillaren: > would it help if the source.ts file was placed at the location of the input file in the VFS? would that result in correct relative links?

You mean recreating the whole folder structure "above" the source & generated files from the original project in the VFS?
Yes, that could work as well. 
schiller-manuel: Why do we need to "recreate" the existing folder / file structure at all?
Isn't the VFS like an overlay on top of the existing folders/files?
tvillaren: You're right, I was not too familiar with VFS upon writing. And we indeed use `createFSBackedSystem` which "hovers a virtual environment" (from the [README](https://github.com/microsoft/TypeScript-Website/tree/v2/packages/typescript-vfs))

But as the 3 files used for validation are currently put in the root folder of the VFS (as `source.ts`, `source.zod.ts` and `source.integration.ts`), we need to place `source.ts` and `source.zod.ts` at their "actual" location + compute related imports for `source.integration.ts` + make sure the extraFiles are placed at the right location as well. 

schiller-manuel: let me know when it is ready to review please 
tvillaren: @schiller-manuel: I believe it is ready for review 👍 

Patch:
diff --git a/example/heros.ts b/example/heros.ts
index e397bb3..887a83c 100644
--- a/example/heros.ts
+++ b/example/heros.ts
@@ -24,6 +24,7 @@ export type SupermanName = Superman["name"];
 export type SupermanInvinciblePower = Superman["powers"][2];
 
 export interface Superman {
+  person: Person;
   name: "superman" | "clark kent" | "kal-l";
   enemies: Record<string, Enemy>;
   age: number;
diff --git a/example/heros.zod.ts b/example/heros.zod.ts
index e1004e6..7495ee2 100644
--- a/example/heros.zod.ts
+++ b/example/heros.zod.ts
@@ -17,6 +17,7 @@ export const enemySchema = personSchema.extend({
 });
 
 export const supermanSchema = z.object({
+  person: personSchema,
   name: z.union([
     z.literal("superman"),
     z.literal("clark kent"),
diff --git a/src/cli.ts b/src/cli.ts
index 1a9f5ad..2b97ad8 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -300,20 +300,54 @@ See more help with --help`,
     if (!Flags.skipValidation) {
       const validatorSpinner = ora("Validating generated types").start();
       if (Flags.all) validatorSpinner.indent = 1;
+
+      const extraFiles = [];
+      for (const io of inputOutputMappings) {
+        if (getImportPath(inputPath, io.input) !== "/") {
+          try {
+            const fileInputPath = join(process.cwd(), io.input);
+            const inputFile = await readFile(fileInputPath, "utf-8");
+            extraFiles.push({
+              sourceText: inputFile,
+              relativePath: io.input,
+            });
+          } catch {
+            validatorSpinner.warn(`File "${io.input}" not found`);
+          }
+
+          try {
+            const fileOutputPath = join(process.cwd(), io.output);
+            const outputFile = await readFile(fileOutputPath, "utf-8");
+            extraFiles.push({
+              sourceText: outputFile,
+              relativePath: io.output,
+            });
+          } catch {
+            validatorSpinner.warn(
+              `File "${io.output}" not found: maybe it hasn't been generated yet?`
+            );
+          }
+        }
+      }
+
       const generationErrors = await worker.validateGeneratedTypesInWorker({
         sourceTypes: {
           sourceText: transformedSourceText,
-          relativePath: "./source.ts",
+          relativePath: input,
         },
         integrationTests: {
-          sourceText: getIntegrationTestFile("./source", "./source.zod"),
+          sourceText: getIntegrationTestFile(
+            getImportPath("./source.integration.ts", input),
+            getImportPath("./source.integration.ts", output || input)
+          ),
           relativePath: "./source.integration.ts",
         },
         zodSchemas: {
-          sourceText: getZodSchemasFile("./source"),
-          relativePath: "./source.zod.ts",
+          sourceText: getZodSchemasFile(getImportPath(output || input, input)),
+          relativePath: output || input,
         },
         skipParseJSDoc: Boolean(generateOptions.skipParseJSDoc),
+        extraFiles,
       });
 
       generationErrors.length
diff --git a/src/core/generate.ts b/src/core/generate.ts
index 83aae7b..154bf15 100644
--- a/src/core/generate.ts
+++ b/src/core/generate.ts
@@ -1,7 +1,6 @@
 import { camel } from "case";
 import { getJsDoc } from "tsutils";
 import ts from "typescript";
-import { relative } from "path";
 import {
   InputOutputMapping,
   JSDocTagFilter,
@@ -28,6 +27,7 @@ import {
   generateZodSchemaVariableStatementForImport,
 } from "./generateZodSchema";
 import { transformRecursiveSchema } from "./transformRecursiveSchema";
+import { areImportPathsEqualIgnoringExtension } from "../utils/getImportPath";
 
 const DEFAULT_GET_SCHEMA = (id: string) => camel(id) + "Schema";
 
@@ -121,11 +121,12 @@ export function generate({
       // Check if we're importing from a mapped file
       const eligibleMapping = inputOutputMappings.find(
         (io: InputOutputMapping) =>
-          relative(
+          areImportPathsEqualIgnoringExtension(
             io.input,
             (node.moduleSpecifier as ts.StringLiteral).text
-          ) === ""
+          )
       );
+
       if (eligibleMapping) {
         const schemaMethod =
           eligibleMapping.getSchemaName || DEFAULT_GET_SCHEMA;
diff --git a/src/core/validateGeneratedTypes.ts b/src/core/validateGeneratedTypes.ts
index c297052..961ad78 100644
--- a/src/core/validateGeneratedTypes.ts
+++ b/src/core/validateGeneratedTypes.ts
@@ -7,6 +7,11 @@ import ts from "typescript";
 import { join, sep, posix } from "path";
 import { resolveDefaultProperties } from "../utils/resolveDefaultProperties";
 import { fixOptionalAny } from "../utils/fixOptionalAny";
+import { getImportIdentifiers } from "../utils/importHandling";
+import {
+  getImportPath,
+  areImportPathsEqualIgnoringExtension,
+} from "../utils/getImportPath";
 
 interface File {
   sourceText: string;
@@ -17,6 +22,7 @@ export interface ValidateGeneratedTypesProps {
   zodSchemas: File;
   integrationTests: File;
   skipParseJSDoc: boolean;
+  extraFiles?: File[];
 }
 
 /**
@@ -27,6 +33,7 @@ export function validateGeneratedTypes({
   zodSchemas,
   integrationTests,
   skipParseJSDoc,
+  extraFiles = [],
 }: ValidateGeneratedTypesProps) {
   // Shared configuration
   const compilerOptions: ts.CompilerOptions = {
@@ -34,22 +41,56 @@ export function validateGeneratedTypes({
     esModuleInterop: true,
   };
 
-  // Create virtual files system with our 3 files
-  const fsMap = createDefaultMapFromNodeModules({
-    target: compilerOptions.target,
-  });
-  const projectRoot = makePosixPath(process.cwd());
-  const src = fixOptionalAny(
+  const sourceFile = ts.createSourceFile(
+    "index.ts",
     skipParseJSDoc
       ? sourceTypes.sourceText
-      : resolveDefaultProperties(sourceTypes.sourceText)
+      : resolveDefaultProperties(sourceTypes.sourceText),
+    ts.ScriptTarget.Latest
   );
 
-  fsMap.set(getPath(sourceTypes), src);
+  // Extracting imports that should be handled as any
+  const importsToHandleAsAny = new Set<string>();
+  const extractImportIdentifiers = (node: ts.Node) => {
+    if (
+      ts.isImportDeclaration(node) &&
+      ts.isStringLiteral(node.moduleSpecifier) &&
+      // If the import declaration is referenced in the extraFiles, it should not be "fixed"
+      !extraFiles.find((file) =>
+        areImportPathsEqualIgnoringExtension(
+          getImportPath(sourceTypes.relativePath, file.relativePath),
+          (node.moduleSpecifier as ts.StringLiteral).text
+        )
+      )
+    ) {
+      if (node.importClause) {
+        const identifiers = getImportIdentifiers(node);
+        identifiers.forEach((i) => importsToHandleAsAny.add(i));
+      }
+    }
+  };
+  ts.forEachChild(sourceFile, extractImportIdentifiers);
+
+  const fixedSourceForValidation = fixOptionalAny(
+    sourceFile,
+    importsToHandleAsAny
+  );
+
+  // Create virtual files system with our files
+  const fsMap = createDefaultMapFromNodeModules({
+    target: compilerOptions.target,
+  });
+
+  fsMap.set(getPath(sourceTypes), fixedSourceForValidation);
   fsMap.set(getPath(zodSchemas), zodSchemas.sourceText);
   fsMap.set(getPath(integrationTests), integrationTests.sourceText);
 
+  if (extraFiles) {
+    extraFiles.forEach((file) => fsMap.set(getPath(file), file.sourceText));
+  }
+
   // Create a virtual typescript environment
+  const projectRoot = makePosixPath(process.cwd());
   const system = createFSBackedSystem(fsMap, projectRoot, ts);
   const env = createVirtualTypeScriptEnvironment(
     system,
diff --git a/src/utils/fixOptionalAny.ts b/src/utils/fixOptionalAny.ts
index 25ef6ed..fca8f47 100644
--- a/src/utils/fixOptionalAny.ts
+++ b/src/utils/fixOptionalAny.ts
@@ -1,5 +1,4 @@
 import ts, { factory as f } from "typescript";
-import { getImportIdentifiers } from "./importHandling";
 
 /**
  * Add optional property to `any` or imported types to workaround comparison issue.
@@ -9,29 +8,16 @@ import { getImportIdentifiers } from "./importHandling";
  * -> https://github.com/fabien0102/ts-to-zod/issues/203
  *
  */
-export function fixOptionalAny(sourceText: string) {
-  const sourceFile = ts.createSourceFile(
-    "index.ts",
-    sourceText,
-    ts.ScriptTarget.Latest
-  );
-
-  // 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);
-
+export function fixOptionalAny(
+  sourceFile: ts.SourceFile,
+  importsToHandleAsAny: Set<string>
+) {
   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)))
+        importsToHandleAsAny.has(node.typeName.getText(sourceFile)))
     );
   }
 
diff --git a/src/utils/getImportPath.ts b/src/utils/getImportPath.ts
index 9733b80..f259eb8 100644
--- a/src/utils/getImportPath.ts
+++ b/src/utils/getImportPath.ts
@@ -1,5 +1,5 @@
 import slash from "slash";
-import { parse, relative } from "path";
+import { join, normalize, parse, relative } from "path";
 
 /**
  * Resolve the path of an import.
@@ -14,3 +14,15 @@ export function getImportPath(from: string, to: string) {
 
   return `${dir}/${name}`;
 }
+
+export function areImportPathsEqualIgnoringExtension(
+  path1: string,
+  path2: string
+): boolean {
+  return relative(normalizePath(path1), normalizePath(path2)) === "";
+}
+
+const normalizePath = (path: string) => {
+  const { dir, name } = parse(normalize(path));
+  return join(dir, name);
+};


Test patch:
diff --git a/src/core/validateGeneratedTypes.test.ts b/src/core/validateGeneratedTypes.test.ts
index 25ce9a1..a53bfdc 100644
--- a/src/core/validateGeneratedTypes.test.ts
+++ b/src/core/validateGeneratedTypes.test.ts
@@ -381,6 +381,346 @@ describe("validateGeneratedTypes", () => {
     expect(errors).toEqual([]);
   });
 
+  it("should return no error if we reference a zod import", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "./hero-module.ts"
+
+      export interface Citizen {
+        hero: Hero
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+      import { heroSchema } from "./zHero.ts"
+
+      export const citizenSchema = z.object({
+        hero: heroSchema
+      });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const extraFiles = [
+      {
+        sourceText: `export type Hero = { name: string; }`,
+        relativePath: "hero-module.ts",
+      },
+      {
+        sourceText: `
+        import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
+        relativePath: "zHero.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,
+      extraFiles,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
+  it("should return no error if we reference a zod import in a subdirectory", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "./zod/hero-module.ts"
+
+      export interface Citizen {
+        hero: Hero
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+      import { heroSchema } from "./zod/zHero.ts"
+
+      export const citizenSchema = z.object({
+        hero: heroSchema
+      });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const extraFiles = [
+      {
+        sourceText: `export type Hero = { name: string; }`,
+        relativePath: `./zod/hero-module.ts`,
+      },
+      {
+        sourceText: `
+        import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
+        relativePath: `./zod/zHero.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,
+      extraFiles,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
+  it("should return no error if we reference a zod import in a parent directory", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "../hero-module.ts"
+
+      export interface Citizen {
+        hero: Hero
+      };
+    `,
+      relativePath: "folder/source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+      import { heroSchema } from "../zHero.ts"
+
+      export const citizenSchema = z.object({
+        hero: heroSchema
+      });
+    `,
+      relativePath: "folder/source.zod.ts",
+    };
+
+    const extraFiles = [
+      {
+        sourceText: `export type Hero = { name: string; }`,
+        relativePath: `hero-module.ts`,
+      },
+      {
+        sourceText: `
+        import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
+        relativePath: `zHero.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,
+      extraFiles,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
+  it("should return no error if we reference a zod import in an exotic path", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "hero-module.ts"
+
+      export interface Citizen {
+        hero: Hero
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+      import { heroSchema } from "../../zod/zHero.ts"
+
+      export const citizenSchema = z.object({
+        hero: heroSchema
+      });
+    `,
+      relativePath: "folder1/folder2/source.zod.ts",
+    };
+
+    const extraFiles = [
+      {
+        sourceText: `export type Hero = { name: string; }`,
+        relativePath: "hero-module.ts",
+      },
+      {
+        sourceText: `
+        import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
+        relativePath: `zod/zHero.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,
+      extraFiles,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
+  it("should return no error if we reference a zod import without its extension", () => {
+    const sourceTypes = {
+      sourceText: `
+      import { Hero } from "./hero-module"
+
+      export interface Citizen {
+        hero: Hero
+      };
+    `,
+      relativePath: "source.ts",
+    };
+
+    const zodSchemas = {
+      sourceText: `// Generated by ts-to-zod
+      import { z } from "zod";
+      import { heroSchema } from "./zHero"
+
+      export const citizenSchema = z.object({
+        hero: heroSchema
+      });
+    `,
+      relativePath: "source.zod.ts",
+    };
+
+    const extraFiles = [
+      {
+        sourceText: `export type Hero = { name: string; }`,
+        relativePath: "hero-module.ts",
+      },
+      {
+        sourceText: `
+        import { z } from "zod";
+        export const heroSchema = z.object({ name: z.string() })`,
+        relativePath: "zHero.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,
+      extraFiles,
+    });
+
+    expect(errors).toEqual([]);
+  });
+
   it("should return no error if we use a deep external import", () => {
     const sourceTypes = {
       sourceText: `
diff --git a/src/utils/getImportPath.test.ts b/src/utils/getImportPath.test.ts
new file mode 100644
index 0000000..406bf89
--- /dev/null
+++ b/src/utils/getImportPath.test.ts
@@ -0,0 +1,72 @@
+import {
+  areImportPathsEqualIgnoringExtension,
+  getImportPath,
+} from "./getImportPath";
+
+describe("getImportPath", () => {
+  it("should return a simple slash when both path are the same", () => {
+    expect(getImportPath("/usr/file.js", "/usr/file.js")).toBe("/");
+  });
+
+  it("should return the import path when two files are in two separate folders", () => {
+    expect(getImportPath("/usr/folder2/file.js", "/usr/folder/file2.js")).toBe(
+      "./../folder/file2"
+    );
+  });
+
+  it("should return the import path when one file is in a subfolder of the other one", () => {
+    expect(
+      getImportPath("/usr/folder/file.js", "/usr/folder/folder2/file2.js")
+    ).toBe("./folder2/file2");
+  });
+
+  it("should return the import path when one file is in a parent of the other one", () => {
+    expect(
+      getImportPath("/usr/folder/folder2/file.js", "/usr/folder/file2.js")
+    ).toBe("./../file2");
+  });
+});
+
+describe("areImportPathsEqualIgnoringExtension", () => {
+  it("should return true for the same path", () => {
+    expect(
+      areImportPathsEqualIgnoringExtension(`folder/file.js`, `folder/file.js`)
+    ).toBe(true);
+  });
+
+  it("should return true for the same path", () => {
+    expect(areImportPathsEqualIgnoringExtension(`./file.js`, `file.js`)).toBe(
+      true
+    );
+  });
+
+  it("should return true for the same path in parent directory", () => {
+    expect(
+      areImportPathsEqualIgnoringExtension(`../file.js`, `../file.js`)
+    ).toBe(true);
+  });
+
+  it("should return true for the same path with different extensions", () => {
+    expect(
+      areImportPathsEqualIgnoringExtension(`folder/file.js`, `folder/file.ts`)
+    ).toBe(true);
+  });
+
+  it("should return true for the same path with different extensions using module notation", () => {
+    expect(areImportPathsEqualIgnoringExtension(`../file.js`, `../file`)).toBe(
+      true
+    );
+  });
+
+  it("should return false for different paths", () => {
+    expect(
+      areImportPathsEqualIgnoringExtension(`folder1/file.js`, `folder2/file.js`)
+    ).toBe(false);
+  });
+
+  it("should return false for different paths with different extensions", () => {
+    expect(
+      areImportPathsEqualIgnoringExtension(`folder1/file.js`, `folder2/file.js`)
+    ).toBe(false);
+  });
+});