Repository: rhys-vdw/ts-auto-guard (ts)

Issue:
Support for explicit file extensions when using ESM
When using ESM with `moduleResolution` set to `nodenext` in `tsconfig.json`, the generated guard.ts will fail to compile:

```
error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
```

To fix it, the generated guard.ts should append `.js` to all its imports.

Pull request description:
Support adding extension to import statements
This PR adds a new option to add custom extension for import statements in generated guards.

This feature is necessary for generated files to work in ESM as `.js` extension is required in import statements.

This PR should close #227.

Patch:
diff --git a/README.md b/README.md
index ea124e4..5a1d32b 100644
--- a/README.md
+++ b/README.md
@@ -176,3 +176,34 @@ import * as Guards from './Person.guard'
 
 export { Guards }
 ```
+
+## Add Custom File Extension to Import Statements
+
+By default, the import statements in generated files won't have any extension.
+However, this doesn't work with ESM, which requires `.js` extension for import statements.
+
+ts-auto-guard supports an `import-extension` flag to set a custom extension in import statements:
+
+```
+ts-auto-guard --import-extension="js"
+```
+
+This will result in the following:
+
+```ts
+// my-project/Person.guard.ts
+
+import { Person } from './Person.js'
+
+export function isPerson(obj: unknown): obj is Person {
+  if (process.env.NODE_ENV === 'production') {
+    return true
+  }
+  const typedObj = obj as Person
+  return (
+    typeof typedObj === 'object' &&
+    // ...normal conditions
+  )
+}
+```
+
diff --git a/src/cli.ts b/src/cli.ts
index 1b550eb..3e59ac3 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -18,6 +18,7 @@ interface ICliOptions {
   debug?: boolean
   'export-all'?: boolean
   'import-guards'?: string
+  'import-extension'?: string
   'prevent-export-imported'?: boolean
   'guard-file-name'?: string
 }
@@ -52,6 +53,13 @@ const optionList = [
     typeLabel: '{underline TypeGuard}',
     type: String,
   },
+  {
+    description:
+      'Add extension to import statements in the generated guards file. Useful for adding .js extension for ESM resolution.',
+    name: 'import-extension',
+    typeLabel: '{underline extension}',
+    type: String,
+  },
   {
     description:
       'Allows customisation of the filename for the generated guards file',
@@ -117,6 +125,7 @@ async function run() {
         debug: options.debug,
         exportAll: options['export-all'],
         importGuards: options['import-guards'],
+        importExtension: options['import-extension'],
         preventExportImported: options['prevent-export-imported'],
         shortCircuitCondition: options.shortcircuit,
         guardFileName: options['guard-file-name'],
diff --git a/src/index.ts b/src/index.ts
index 33c1fd4..f73c28c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1017,6 +1017,7 @@ function createAddDependency(dependencies: Dependencies): IAddDependency {
 export interface IProcessOptions {
   exportAll?: boolean
   importGuards?: string
+  importExtension?: string
   preventExportImported?: boolean
   shortCircuitCondition?: string
   debug?: boolean
@@ -1078,6 +1079,9 @@ export function processProject(
   project: Project,
   options: Readonly<IProcessOptions> = { debug: false }
 ): void {
+  const importExtension = options.importExtension
+    ? `.${options.importExtension}`
+    : ''
   const guardFileName = options.guardFileName || 'guard'
   if (guardFileName.match(/[*/]/))
     throw new Error('guardFileName must not contain special characters')
@@ -1193,7 +1197,8 @@ export function processProject(
             }
 
             let moduleSpecifier =
-              outFile.getRelativePathAsModuleSpecifierTo(importFile)
+              outFile.getRelativePathAsModuleSpecifierTo(importFile) +
+              importExtension
 
             if (importFile.isInNodeModules()) {
               // Packages within node_modules should not be referenced via relative path
@@ -1240,7 +1245,7 @@ export function processProject(
             .split('/')
             .reverse()[0]
             .replace(/\.(ts|tsx|d\.ts)$/, '')
-        const importStatement = `import * as ${options.importGuards} from "${relativeOutPath}";`
+        const importStatement = `import * as ${options.importGuards} from "${relativeOutPath}${importExtension}";`
         const exportStatement = `export { ${options.importGuards} };`
         const { hasImport, hasExport, statements } = sourceFile
           .getStatements()
@@ -1248,7 +1253,7 @@ export function processProject(
             (reduced, node) => {
               const nodeText = node.getText().replace(/\s{2,}/g, ' ')
               reduced.hasImport ||= nodeText.includes(
-                `import * as ${options.importGuards}`
+                `import * as ${options.importGuards}${importExtension}`
               )
               reduced.hasExport ||= nodeText.includes(
                 `export { ${options.importGuards} }`


Test patch:
diff --git a/tests/features/adds_extension_to_import_statements.test.ts b/tests/features/adds_extension_to_import_statements.test.ts
new file mode 100644
index 0000000..5b90b40
--- /dev/null
+++ b/tests/features/adds_extension_to_import_statements.test.ts
@@ -0,0 +1,52 @@
+import { testProcessProject } from '../generate'
+
+testProcessProject(
+  'adds extension to import statements',
+  {
+    'test.ts': `
+      /** @see {isTestType} ts-auto-guard:type-guard */
+      export interface TestType {
+          someKey: string | number
+      }
+      `,
+    'test-list.ts': `
+      import { TestType } from './test.js'
+
+      /** @see {isTestTypeList} ts-auto-guard:type-guard */
+      export type TestTypeList = Array<TestType>
+      `,
+  },
+  {
+    'test.ts': null,
+    'test-list.ts': null,
+    'test-list.guard.ts': `
+      import { isTestType } from "./test.guard.js";
+      import { TestTypeList } from "./test-list.js";
+
+      export function isTestTypeList(obj: unknown): obj is TestTypeList {
+          const typedObj = obj as TestTypeList
+          return (
+              Array.isArray(typedObj) &&
+              typedObj.every((e: any) =>
+                  isTestType(e) as boolean
+              )
+          )
+      }
+      `,
+    'test.guard.ts': `
+        import { TestType } from "./test.js";
+
+        export function isTestType(obj: unknown): obj is TestType {
+            const typedObj = obj as TestType
+            return (
+                (typedObj !== null &&
+                    typeof typedObj === "object" ||
+                    typeof typedObj === "function") &&
+                (typeof typedObj["someKey"] === "string" ||
+                    typeof typedObj["someKey"] === "number")
+            )
+        }
+        `,
+  },
+  { options: { importExtension: 'js' } }
+)