Repository: asyncapi/modelina (ts)

Issue:
Support verbatimModuleSyntax in TypeScript Generator
#### Reason/Context
Currently the typescript generator does not explicitly export/import interfaces as types. This is an issue when the typescript option 'verbatimModuleSyntax' is enabled and can potentially cause some other pitfalls.


#### Description
Add a new flag (or make this the default behaviour when using interfaces, afaik that should not cause any issues) that changes the behaviour of 'src/generators/typescript/TypeScriptDependencyManager.ts' so it imports and exports interfaces as types.

Before:
```typescript
import { PublicUser } from './PublicUser'
interface NewStatePayload {
  members: PublicUser[]
}
export { NewStatePayload }
```
After:
```typescript
import type { PublicUser } from './PublicUser'
interface NewStatePayload {
  members: PublicUser[]
}
export type { NewStatePayload }
```

Pull request description:
feat: export and import interfaces as types when using named imports and ESM Module Syntax
## Description
When using modelType 'interface', exportType 'named' and moduleSystem 'ESM' export and import interfaces as types to support verbatimModuleSyntax.
This should not be a breaking change, interfaces are types even when not using verbatimModuleSyntax, explicitly marking them as types is optional in that case though.

## Related Issue
fixes #1793 

## Checklist
- [x] The code follows the project's coding standards and is properly linted (`npm run lint`).
- [x] Tests have been added or updated to cover the changes.
- [ ] Documentation has been updated to reflect the changes.
- [x] All tests pass successfully locally.(`npm run test`).

## Additional Notes
We might want to document the fact that one needs to use named exports when using interfaces and verbatimModuleSyntax. Im not sure where this documentation should go though, please point me in the right direction here :D 


Patch:
diff --git a/examples/README.md b/examples/README.md
index 332852ec..b46db933 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -135,6 +135,7 @@ These are all specific examples only relevant to the TypeScript generator:
 - [typescript-generate-example](./typescript-generate-example) - A basic example of how to use Modelina and output a TypeScript class with an example function.
 - [typescript-generate-marshalling](./typescript-generate-marshalling) - A basic example of how to use the un/marshalling functionality of the typescript class.
 - [typescript-generate-comments](./typescript-generate-comments) - A basic example of how to generate TypeScript interfaces with comments from description and example fields.
+- [typescript-interfaces-as-types](./typescript-interfaces-as-types) - A basic example that generate the models as interfaces and uses ESM module system syntax to import and export them as types.
 - [typescript-use-esm](./typescript-use-esm) - A basic example that generate the models to use ESM module system.
 - [typescript-use-cjs](./typescript-use-cjs) - A basic example that generate the models to use CJS module system.
 - [typescript-generate-jsonbinpack](./typescript-generate-jsonbinpack) - A basic example showing how to generate models that include [jsonbinpack](https://github.com/sourcemeta/jsonbinpack) functionality.
diff --git a/examples/typescript-interfaces-as-types/README.md b/examples/typescript-interfaces-as-types/README.md
new file mode 100644
index 00000000..57c7ce78
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/README.md
@@ -0,0 +1,17 @@
+# TypeScript interface as types
+
+A basic example of how to use Modelina and output TypeScript interfaces that are exported as types using ESM Module Syntax.
+
+## How to run this example
+
+Run this example using:
+
+```sh
+npm i && npm run start
+```
+
+If you are on Windows, use the `start:windows` script instead:
+
+```sh
+npm i && npm run start:windows
+```
diff --git a/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap b/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap
new file mode 100644
index 00000000..5e99c498
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Should be able to render typescript interfaces as types and should log expected output to console 1`] = `
+Array [
+  "import type {OtherModel} from './OtherModel';
+interface Root {
+  email?: string;
+  otherModel?: OtherModel;
+}
+export type { Root };",
+]
+`;
+
+exports[`Should be able to render typescript interfaces as types and should log expected output to console 2`] = `
+Array [
+  "
+interface OtherModel {
+  streetName?: string;
+  additionalProperties?: Map<string, any>;
+}
+export type { OtherModel };",
+]
+`;
diff --git a/examples/typescript-interfaces-as-types/index.spec.ts b/examples/typescript-interfaces-as-types/index.spec.ts
new file mode 100644
index 00000000..d83e7f3c
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/index.spec.ts
@@ -0,0 +1,16 @@
+const spy = jest.spyOn(global.console, 'log').mockImplementation(() => {
+  return;
+});
+import { generate } from './index';
+
+describe('Should be able to render typescript interfaces as types', () => {
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+  test('and should log expected output to console', async () => {
+    await generate();
+    expect(spy.mock.calls.length).toEqual(2);
+    expect(spy.mock.calls[0]).toMatchSnapshot();
+    expect(spy.mock.calls[1]).toMatchSnapshot();
+  });
+});
diff --git a/examples/typescript-interfaces-as-types/index.ts b/examples/typescript-interfaces-as-types/index.ts
new file mode 100644
index 00000000..57f909d4
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/index.ts
@@ -0,0 +1,34 @@
+import { TypeScriptGenerator } from '../../src';
+
+const generator = new TypeScriptGenerator({
+  modelType: 'interface',
+  moduleSystem: 'ESM'
+});
+const jsonSchemaDraft7 = {
+  $schema: 'http://json-schema.org/draft-07/schema#',
+  type: 'object',
+  additionalProperties: false,
+  properties: {
+    email: {
+      type: 'string',
+      format: 'email'
+    },
+    other_model: {
+      type: 'object',
+      $id: 'OtherModel',
+      properties: { street_name: { type: 'string' } }
+    }
+  }
+};
+
+export async function generate(): Promise<void> {
+  const models = await generator.generateCompleteModels(jsonSchemaDraft7, {
+    exportType: 'named'
+  });
+  for (const model of models) {
+    console.log(model.result);
+  }
+}
+if (require.main === module) {
+  generate();
+}
diff --git a/examples/typescript-interfaces-as-types/package-lock.json b/examples/typescript-interfaces-as-types/package-lock.json
new file mode 100644
index 00000000..02096c02
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/package-lock.json
@@ -0,0 +1,10 @@
+{
+  "name": "typescript-interfaces-as-types",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "hasInstallScript": true
+    }
+  }
+}
diff --git a/examples/typescript-interfaces-as-types/package.json b/examples/typescript-interfaces-as-types/package.json
new file mode 100644
index 00000000..80bd9b40
--- /dev/null
+++ b/examples/typescript-interfaces-as-types/package.json
@@ -0,0 +1,10 @@
+{
+  "config" : { "example_name" : "typescript-interfaces-as-types" },
+  "scripts": {
+    "install": "cd ../.. && npm i",
+    "start": "../../node_modules/.bin/ts-node --cwd ../../ ./examples/$npm_package_config_example_name/index.ts",
+    "start:windows": "..\\..\\node_modules\\.bin\\ts-node --cwd ..\\..\\ .\\examples\\%npm_package_config_example_name%\\index.ts",
+    "test": "../../node_modules/.bin/jest --config=../../jest.config.js ./examples/$npm_package_config_example_name/index.spec.ts",
+    "test:windows": "..\\..\\node_modules\\.bin\\jest --config=..\\..\\jest.config.js examples/%npm_package_config_example_name%/index.spec.ts"
+  }
+}
diff --git a/src/generators/typescript/TypeScriptDependencyManager.ts b/src/generators/typescript/TypeScriptDependencyManager.ts
index c8a2c948..461ab143 100644
--- a/src/generators/typescript/TypeScriptDependencyManager.ts
+++ b/src/generators/typescript/TypeScriptDependencyManager.ts
@@ -1,7 +1,11 @@
 import { AbstractDependencyManager } from '../AbstractDependencyManager';
 import { renderJavaScriptDependency } from '../../helpers';
 import { ConstrainedMetaModel } from '../../models';
-import { TypeScriptExportType, TypeScriptOptions } from './TypeScriptGenerator';
+import {
+  TypeScriptExportType,
+  TypeScriptModelType,
+  TypeScriptOptions
+} from './TypeScriptGenerator';
 
 export class TypeScriptDependencyManager extends AbstractDependencyManager {
   constructor(
@@ -14,19 +18,32 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager {
   /**
    * Simple helper function to add a dependency correct based on the module system that the user defines.
    */
-  addTypeScriptDependency(toImport: string, fromModule: string): void {
-    const dependencyImport = this.renderDependency(toImport, fromModule);
+  addTypeScriptDependency(
+    toImport: string,
+    fromModule: string,
+    modelType: TypeScriptModelType
+  ): void {
+    const dependencyImport = this.renderDependency(
+      toImport,
+      fromModule,
+      modelType
+    );
     this.addDependency(dependencyImport);
   }
 
   /**
    * Simple helper function to render a dependency based on the module system that the user defines.
    */
-  renderDependency(toImport: string, fromModule: string): string {
+  renderDependency(
+    toImport: string,
+    fromModule: string,
+    modelType: TypeScriptModelType
+  ): string {
     return renderJavaScriptDependency(
       toImport,
       fromModule,
-      this.options.moduleSystem
+      this.options.moduleSystem,
+      modelType === 'interface' ? 'type' : 'value'
     );
   }
 
@@ -35,11 +52,16 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager {
    */
   renderCompleteModelDependencies(
     model: ConstrainedMetaModel,
-    exportType: TypeScriptExportType
+    exportType: TypeScriptExportType,
+    modelType: TypeScriptModelType
   ): string {
     const dependencyObject =
       exportType === 'named' ? `{${model.name}}` : model.name;
-    return this.renderDependency(dependencyObject, `./${model.name}`);
+    return this.renderDependency(
+      dependencyObject,
+      `./${model.name}`,
+      modelType
+    );
   }
 
   /**
@@ -47,16 +69,23 @@ export class TypeScriptDependencyManager extends AbstractDependencyManager {
    */
   renderExport(
     model: ConstrainedMetaModel,
-    exportType: TypeScriptExportType
+    exportType: TypeScriptExportType,
+    modelType: TypeScriptModelType
   ): string {
     const cjsExport =
       exportType === 'default'
         ? `module.exports = ${model.name};`
         : `exports.${model.name} = ${model.name};`;
-    const esmExport =
+    const esmExportValue =
       exportType === 'default'
         ? `export default ${model.name};\n`
         : `export { ${model.name} };`;
+    const esmExportType =
+      exportType === 'default'
+        ? `export default ${model.name};\n`
+        : `export type { ${model.name} };`;
+    const esmExport =
+      modelType === 'interface' ? esmExportType : esmExportValue;
     return this.options.moduleSystem === 'CJS' ? cjsExport : esmExport;
   }
 }
diff --git a/src/generators/typescript/TypeScriptGenerator.ts b/src/generators/typescript/TypeScriptGenerator.ts
index 074bdeb3..96932f38 100644
--- a/src/generators/typescript/TypeScriptGenerator.ts
+++ b/src/generators/typescript/TypeScriptGenerator.ts
@@ -39,13 +39,14 @@ import { TypeScriptDependencyManager } from './TypeScriptDependencyManager';
 
 export type TypeScriptModuleSystemType = 'ESM' | 'CJS';
 export type TypeScriptExportType = 'named' | 'default';
+export type TypeScriptModelType = 'class' | 'interface';
 export interface TypeScriptOptions
   extends CommonGeneratorOptions<
     TypeScriptPreset,
     TypeScriptDependencyManager
   > {
   renderTypes: boolean;
-  modelType: 'class' | 'interface';
+  modelType: TypeScriptModelType;
   enumType: 'enum' | 'union';
   mapType: 'indexedObject' | 'map' | 'record';
   typeMapping: TypeMapping<TypeScriptOptions, TypeScriptDependencyManager>;
@@ -210,12 +211,14 @@ export class TypeScriptGenerator extends AbstractGenerator<
     const modelDependencyImports = modelDependencies.map((model) => {
       return dependencyManagerToUse.renderCompleteModelDependencies(
         model,
-        completeModelOptionsToUse.exportType
+        completeModelOptionsToUse.exportType,
+        optionsToUse.modelType
       );
     });
     const modelExport = dependencyManagerToUse.renderExport(
       args.constrainedModel,
-      completeModelOptionsToUse.exportType
+      completeModelOptionsToUse.exportType,
+      optionsToUse.modelType
     );
 
     const modelCode = `${outputModel.result}\n${modelExport}`;
diff --git a/src/helpers/DependencyHelpers.ts b/src/helpers/DependencyHelpers.ts
index 472101a6..24fb302f 100644
--- a/src/helpers/DependencyHelpers.ts
+++ b/src/helpers/DependencyHelpers.ts
@@ -10,11 +10,18 @@ import { ConstrainedMetaModel } from '../models';
 export function renderJavaScriptDependency(
   toImport: string,
   fromModule: string,
-  moduleSystem: 'CJS' | 'ESM'
+  moduleSystem: 'CJS' | 'ESM',
+  importType: 'type' | 'value' = 'value'
 ): string {
-  return moduleSystem === 'CJS'
-    ? `const ${toImport} = require('${fromModule}');`
-    : `import ${toImport} from '${fromModule}';`;
+  const importValueStatement =
+    moduleSystem === 'CJS'
+      ? `const ${toImport} = require('${fromModule}');`
+      : `import ${toImport} from '${fromModule}';`;
+  const importTypeStatement =
+    moduleSystem === 'CJS'
+      ? `const ${toImport} = require('${fromModule}');`
+      : `import type ${toImport} from '${fromModule}';`;
+  return importType === 'type' ? importTypeStatement : importValueStatement;
 }
 
 /**


Test patch:
diff --git a/test/generators/typescript/TypeScriptGenerator.spec.ts b/test/generators/typescript/TypeScriptGenerator.spec.ts
index 085501ec..74e8a804 100644
--- a/test/generators/typescript/TypeScriptGenerator.spec.ts
+++ b/test/generators/typescript/TypeScriptGenerator.spec.ts
@@ -381,6 +381,19 @@ ${content}`;
     expect(models[1].result).toMatchSnapshot();
   });
 
+  test('should render models and their dependencies for ESM module system with named exports as interfaces', async () => {
+    generator = new TypeScriptGenerator({
+      moduleSystem: 'ESM',
+      modelType: 'interface'
+    });
+    const models = await generator.generateCompleteModels(doc, {
+      exportType: 'named'
+    });
+    expect(models).toHaveLength(2);
+    expect(models[0].result).toMatchSnapshot();
+    expect(models[1].result).toMatchSnapshot();
+  });
+
   describe('AsyncAPI with polymorphism', () => {
     const asyncapiDoc = {
       asyncapi: '2.4.0',
diff --git a/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap b/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap
index cdacd89e..679ad163 100644
--- a/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap
+++ b/test/generators/typescript/__snapshots__/TypeScriptGenerator.spec.ts.snap
@@ -948,6 +948,31 @@ class OtherModel {
 export { OtherModel };"
 `;
 
+exports[`TypeScriptGenerator should render models and their dependencies for ESM module system with named exports as interfaces 1`] = `
+"import type {OtherModel} from './OtherModel';
+interface Address {
+  streetName: string;
+  city: string;
+  state: string;
+  houseNumber: number;
+  marriage?: boolean;
+  members?: string | number | boolean;
+  arrayType: (string | number | any)[];
+  otherModel?: OtherModel;
+  additionalProperties?: Map<string, any | string>;
+}
+export type { Address };"
+`;
+
+exports[`TypeScriptGenerator should render models and their dependencies for ESM module system with named exports as interfaces 2`] = `
+"
+interface OtherModel {
+  streetName?: string;
+  additionalProperties?: Map<string, any>;
+}
+export type { OtherModel };"
+`;
+
 exports[`TypeScriptGenerator should render union \`enum\` values 1`] = `
 "enum States {
   NUMBER_2 = 2,