Skip to content

Conversation

@DaxServer
Copy link
Owner

This change adds support for handling generic interfaces in the
InterfaceTypeHandler. Previously, the handler only supported
non-generic interfaces. Now, if an interface has type parameters,
the handler will generate a function that takes the type parameters
as arguments and returns the composite type for the interface.

The changes include:

  • Adding support for parsing type parameters and heritage clauses
    in the handle method.
  • Implementing the createGenericInterfaceFunction method to
    generate the function for generic interfaces.
  • Updating the parseGenericTypeCall method to handle generic
    type references in heritage clauses.

These changes allow the InterfaceTypeHandler to correctly
generate the TypeBox schema for generic interfaces, which is
necessary for supporting more complex validation schemas.

This change adds support for handling generic interfaces in the
`InterfaceTypeHandler`. Previously, the handler only supported
non-generic interfaces. Now, if an interface has type parameters,
the handler will generate a function that takes the type parameters
as arguments and returns the composite type for the interface.

The changes include:

- Adding support for parsing type parameters and heritage clauses
  in the `handle` method.
- Implementing the `createGenericInterfaceFunction` method to
  generate the function for generic interfaces.
- Updating the `parseGenericTypeCall` method to handle generic
  type references in heritage clauses.

These changes allow the `InterfaceTypeHandler` to correctly
generate the TypeBox schema for generic interfaces, which is
necessary for supporting more complex validation schemas.
@coderabbitai
Copy link

coderabbitai bot commented Aug 26, 2025

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added support for generating parameterized TypeBox definitions from generic interfaces.
    • Handles interfaces extending generics (e.g., converts A to A(Type.Number())).
    • Automatically creates corresponding Static type aliases for generic and non-generic interfaces.
    • Conditionally includes TSchema in imports when generics are present.
  • Documentation

    • Updated architecture docs to cover generic interface handling and composition behavior.
  • Tests

    • Added tests for generic interfaces and extends scenarios.
    • Enhanced test utilities to optionally include TSchema in generated imports.

Walkthrough

Adds support for generic TypeScript interfaces to the TypeBox code generator: handlers now emit parameterized factory functions for interfaces with type parameters, parsers generate corresponding generic type aliases (Static<ReturnType<...>>), import generation conditionally includes TSchema, tests and docs updated.

Changes

Cohort / File(s) Summary
Documentation
ARCHITECTURE.md
Documents generic interface handling: parameterized factory functions, parsing of generic heritage (e.g., A → A(Type.Number())), and TypeBox schema parameterization.
TypeBox Interface Handler
src/handlers/typebox/object/interface-type-handler.ts
Detects interface type parameters and emits generic factory functions via createGenericInterfaceFunction; parses extends clauses including generic calls; composes extended types with base object; adds helpers parseGenericTypeCall and createTypeExpression.
Parsers
src/parsers/parse-interfaces.ts
Branches to parseGenericInterface or parseRegularInterface based on type parameters; emits const factory for the schema and either addGenericTypeAlias (type Name = Static<ReturnType<typeof Name>>) or addStaticTypeAlias for non-generics.
Codegen Imports
src/ts-morph-codegen.ts
Scans source for interfaces with type parameters and conditionally includes type TSchema in the TypeBox named imports; replaces fixed import list with a dynamically built one.
Tests
tests/handlers/typebox/interfaces.test.ts
Adds "generic types" test asserting generated generic factory const A = <T extends TSchema>(T: T) => Type.Object({ a: T }), alias type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>, composite B using A(Type.Number()), and corresponding aliases.
Test Utilities
tests/utils.ts
Adds withTSchema flag to control inclusion of TSchema in import generation; updates formatWithPrettier and generateFormattedCode signatures to accept the flag and drive imports.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant TS as ts-morph-codegen
  participant P as parse-interfaces.ts
  participant H as interface-type-handler.ts
  participant TB as TypeBox

  Note over TS: Analyze source file for generic interfaces
  TS->>TS: hasGenericInterfaces = any interface has type params?
  TS->>TB: Import { Type, type Static, [type TSchema] }

  loop For each InterfaceDeclaration
    P->>P: Gather typeParameters, isExported
    alt Generic interface
      P->>H: parseGenericInterface(interfaceDecl, isExported)
      H->>H: createGenericInterfaceFunction(params)
      H->>H: parse extends (incl. GenericName<Arg>)
      H->>TB: Build Composite([extends..., Type.Object({...})])
      P->>P: addGenericTypeAlias(name, typeParameters, isExported)
    else Non-generic interface
      P->>H: parseRegularInterface(interfaceDecl, isExported)
      H->>TB: Type.Object({...}) [+ Composite if extends]
      P->>P: addStaticTypeAlias(name, isExported)
    end
  end
Loading
sequenceDiagram
  autonumber
  participant H as interface-type-handler.ts
  participant TS as ts-morph (AST)
  participant TB as TypeBox

  H->>TS: node.getTypeParameters()
  alt has type params
    H->>H: createGenericInterfaceFunction
    H->>TS: node.getHeritageClauses(Extends)
    H->>H: parseGenericTypeCall for each type (Name<Arg> → Name(Type.X()))
    H->>TB: Return Composite([extended..., Type.Object(...)])
  else no type params
    H->>TS: heritage extends?
    H->>TB: Type.Object(...) or Composite([...])
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7439696 and dbdad80.

📒 Files selected for processing (2)
  • src/handlers/typebox/object/interface-type-handler.ts (2 hunks)
  • tests/utils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/handlers/typebox/object/interface-type-handler.ts
  • tests/utils.ts
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch generic-interfaces

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (11)
ARCHITECTURE.md (2)

224-224: Docs: call out current generic-heritage limitations (single arg, primitives) or align with implementation

The text claims we “convert expressions like A to A(Type.Number())”. The current handler does this only for a single generic type argument and only for primitive args (number/string/boolean). Nested generics (e.g., A<B>), multiple args (A<K, V>), unions (A<string | number>), and qualified names (ns.A) are not handled by the current implementation and will generate invalid code paths. Please either (a) narrow the docs to reflect the implemented scope or (b) expand implementation per my inline suggestions in code.


260-261: Docs: “parse-interfaces.ts” note should clarify alias form for generics and import needs

The note about generating parameterized functions and generic type aliases is good. Please add one line clarifying that when a generic interface is local, we must import “type TSchema” and that the emitted alias uses Static<ReturnType<typeof Foo>>. Also call out the limitations mentioned above so expectations match the actual generator behavior.

src/handlers/typebox/object/interface-type-handler.ts (2)

52-122: Generic path duplicates heritage parsing logic; extract a shared helper to avoid drift

The generic path re-implements the same Extends parsing as the non-generic path. Please factor both call sites through a single helper that consumes HeritageClause[] and returns ts.Expression[] for extended types. This reduces divergence and centralizes bug fixes (see prior comment).

Here’s a minimal refactor to reuse the same logic:

@@
-    // Handle heritage clauses for generic interfaces
-    if (heritageClauses.length > 0) {
-      const extendedTypes: ts.Expression[] = []
-      for (const heritageClause of heritageClauses) {
-        if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) {
-          continue
-        }
-        for (const typeNode of heritageClause.getTypeNodes()) {
-          const typeText = typeNode.getText()
-          const genericCall = this.parseGenericTypeCall(typeText)
-          if (genericCall) {
-            extendedTypes.push(genericCall)
-          } else {
-            extendedTypes.push(ts.factory.createIdentifier(typeText))
-          }
-        }
-      }
-      if (extendedTypes.length > 0) {
-        const allTypes = [...extendedTypes, baseObjectType]
-        functionBody = makeTypeCall('Composite', [
-          ts.factory.createArrayLiteralExpression(allTypes, true),
-        ])
-      }
-    }
+    // Handle heritage clauses (same logic as non-generic path)
+    const extended = this.collectExtendedTypes(heritageClauses)
+    if (extended.length > 0) {
+      const allTypes = [...extended, baseObjectType]
+      functionBody = makeTypeCall('Composite', [
+        ts.factory.createArrayLiteralExpression(allTypes, true),
+      ])
+    }

And add:

+  private collectExtendedTypes(heritageClauses: HeritageClause[]): ts.Expression[] {
+    const out: ts.Expression[] = []
+    for (const hc of heritageClauses) {
+      if (hc.getToken() !== ts.SyntaxKind.ExtendsKeyword) continue
+      for (const t of hc.getTypeNodes()) out.push(this.parseHeritageTypeNode(t))
+    }
+    return out
+  }

102-111: Consider preserving original type parameter constraints along with TSchema

Right now every type parameter becomes <T extends TSchema>, ignoring any original constraints (e.g., <T extends string>). If keeping TSchema is required, we could intersect constraints when they’re simple (e.g., T extends TSchema & SomeConstraint) or gate this behind an option. If out of scope, at least document that original generic constraints are not preserved.

Would you like me to prototype a constraint-preserving version that emits T extends TSchema when unconstrained and T extends TSchema & (OriginalConstraint) when constrained?

tests/handlers/typebox/interfaces.test.ts (1)

204-232: Great first coverage for generics; add a few edge cases to pin down behavior

Consider adding:

  • Generic extends with type param passthrough: interface B<T> extends A<T> { b: number }
  • Qualified base: namespace NS { export interface A<T> { a: T } } interface B extends NS.A<number> { b: number }
  • Multiple type args: interface A<K, V> { ... } interface B extends A<string, number> { ... } (document expected behavior if unsupported)
  • Nested generic arg: interface B extends A<Array<number>> { ... }

These will surface current gaps in the handler and prevent regressions once we switch to AST-driven parsing.

If you want, I can push a patch adding these tests and marking the unsupported ones with todo/skip until the handler is fixed.

tests/utils.ts (4)

6-9: Minor: simplify import builder API

typeboxImport currently accepts a string parameter used only for “, type TSchema”. A boolean is clearer.

Apply this tidy-up:

-const typeboxImport = (tschema: string = '') =>
-  `import { Type, type Static${tschema} } from "@sinclair/typebox";\n`
-const typeboxImportTSchema = () => typeboxImport(', type TSchema')
+const typeboxImport = (withTSchema: boolean = false) =>
+  `import { Type, type Static${withTSchema ? ', type TSchema' : ''} } from "@sinclair/typebox";\n`
+const typeboxImportTSchema = () => typeboxImport(true)

14-21: formatWithPrettier: parameter naming

The parameter tschema is a boolean, but the name suggests a type. Consider renaming to withTSchema for clarity. No functional change.

-export const formatWithPrettier = (
-  input: string,
-  addImport: boolean = true,
-  tschema: boolean = false,
-): string => {
-  const importTypebox = tschema ? typeboxImportTSchema() : typeboxImport()
+export const formatWithPrettier = (
+  input: string,
+  addImport: boolean = true,
+  withTSchema: boolean = false,
+): string => {
+  const importTypebox = withTSchema ? typeboxImportTSchema() : typeboxImport()
   const code = addImport ? `${importTypebox}${input}` : input

28-29: generateFormattedCode: mirror the naming change for consistency

Follow-up to the previous nit.

-  tschema: boolean = false,
+  withTSchema: boolean = false,

37-38: Pass-through flag name

Follow-up to the previous nit.

-  return formatWithPrettier(code, false, tschema)
+  return formatWithPrettier(code, false, withTSchema)
src/parsers/parse-interfaces.ts (2)

74-100: Generic path is correct; minor duplication nit

Recomputing typeParameters here is harmless but unnecessary since it was already obtained in the caller. Consider threading it through to avoid duplicate work and slightly reduce surface area.

If you want to trim the duplication:

-  private parseGenericInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
-    const interfaceName = interfaceDecl.getName()
-    const typeParameters = interfaceDecl.getTypeParameters()
+  private parseGenericInterface(
+    interfaceDecl: InterfaceDeclaration,
+    isExported: boolean,
+    typeParameters = interfaceDecl.getTypeParameters(),
+  ): void {
+    const interfaceName = interfaceDecl.getName()

And at the call site:

-  if (typeParameters.length > 0) {
-    this.parseGenericInterface(interfaceDecl, isExported)
+  if (typeParameters.length > 0) {
+    this.parseGenericInterface(interfaceDecl, isExported, typeParameters)

98-100: Optional: centralize alias emission to reduce duplication

Consider extending addStaticTypeAlias to accept optional typeParameters and reuse it for both generic and non-generic cases. Keeps alias creation logic in one place.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b3efdca and 7439696.

📒 Files selected for processing (6)
  • ARCHITECTURE.md (2 hunks)
  • src/handlers/typebox/object/interface-type-handler.ts (2 hunks)
  • src/parsers/parse-interfaces.ts (3 hunks)
  • src/ts-morph-codegen.ts (1 hunks)
  • tests/handlers/typebox/interfaces.test.ts (1 hunks)
  • tests/utils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
tests/handlers/typebox/interfaces.test.ts (1)
tests/utils.ts (3)
  • createSourceFile (10-12)
  • generateFormattedCode (25-38)
  • formatWithPrettier (14-23)
tests/utils.ts (1)
src/ts-morph-codegen.ts (1)
  • generateCode (17-116)
src/handlers/typebox/object/interface-type-handler.ts (1)
src/utils/typebox-codegen-utils.ts (1)
  • makeTypeCall (3-12)
src/parsers/parse-interfaces.ts (1)
src/utils/typebox-call.ts (1)
  • getTypeBoxType (9-21)
🔇 Additional comments (7)
src/handlers/typebox/object/interface-type-handler.ts (2)

11-18: Good split between generic vs non-generic interfaces

Branching early on type parameters keeps the handle path simple and avoids threading generics through the non-generic code path. This aligns with the tests.


49-50: Composite ordering is correct

Base types first and then the current object is the expected order for Type.Composite([...bases, current]).

src/ts-morph-codegen.ts (2)

33-37: Generic import gating is correct but only checks local interfaces

The detection correctly enables “type TSchema” import when local source contains generic interfaces. If a local interface extends an imported generic, this remains fine because the callsite doesn’t need TSchema. Just calling it out for awareness.


39-53: Import structure: mixed named specifiers with type-only flags is idiomatic

The mix of value import (Type) and type-only imports (Static, TSchema) generates the desired import { Type, type Static, type TSchema } form. Looks good.

src/parsers/parse-interfaces.ts (3)

32-41: Solid generic vs. non-generic split

The gating on typeParameters.length is clear and keeps flows readable. No issues.


43-72: Non-generic handling looks good

Emitting a TypeBox const and delegating to addStaticTypeAlias preserves existing behavior cleanly.


124-133: [r�un_scripts]

#!/bin/bash
set -euo pipefail

echo "TypeScript version (package.json):"
jq -r '.devDependencies.typescript // .dependencies.typescript // .peerDependencies.typescript // "not found"' package.json

echo "ts-morph version (package.json):"
jq -r '.devDependencies["ts-morph"] // .dependencies["ts-morph"] // .peerDependencies["ts-morph"] // "not found"' package.json

echo
echo "TypeScript version from lockfile (package-lock.json):"
grep -R '"typescript":' package-lock.json | head -n20 || true

echo
echo "ts-morph version from lockfile (package-lock.json):"
grep -R '"ts-morph":' package-lock.json | head -n20 || true

echo
echo "Check Static and TSchema imports (double quotes):"
rg 'import { Static, TSchema } from "@sinclair/typebox"' -g 'src/**/*.ts' -C2 || true

echo
echo "Check Static and TSchema imports (single quotes):"
rg "import { Static, TSchema } from '@sinclair/typebox'" -g 'src/**/*.ts' -C2 || true

Comment on lines 26 to 39
for (const heritageClause of heritageClauses) {
if (heritageClause.getToken() === ts.SyntaxKind.ExtendsKeyword) {
for (const typeNode of heritageClause.getTypeNodes()) {
// For interface inheritance, we reference the already processed interface by name
const referencedTypeName = typeNode.getText()
extendedTypes.push(ts.factory.createIdentifier(referencedTypeName))
if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) {
continue
}

for (const typeNode of heritageClause.getTypeNodes()) {
const typeText = typeNode.getText()
const genericCall = this.parseGenericTypeCall(typeText)
if (genericCall) {
extendedTypes.push(genericCall)
} else {
extendedTypes.push(ts.factory.createIdentifier(typeText))
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Extends parsing via string getText() is fragile; qualified names and nested generics break generation

Using typeNode.getText() + regex to detect generics and falling back to ts.factory.createIdentifier(typeText) will produce invalid AST in several common cases:

  • Qualified names: extends ns.A → Identifier("ns.A") is invalid; must be a property access expression.
  • Nested generics: extends A<B> → parseGenericTypeCall fails and we fall back to Identifier("A<B>"), which is invalid.
  • Multiple type args: extends A<K, V> → we pass a single argument "K, V" and then create Identifier("K, V") (invalid).
  • Unions/arrays/complex args: A<string | number>, A<Record<string, number>> are not handled.

We need to construct these from the AST, not from text. See suggested refactor below; it removes regex parsing and builds the correct expressions for identifiers, property access (ns.A), and generic calls with N args.

Apply this diff to replace the fragile loop with AST-driven construction (uses ExpressionWithTypeArguments and supports multiple args and qualifiers):

@@
-    for (const heritageClause of heritageClauses) {
-      if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) {
-        continue
-      }
-
-      for (const typeNode of heritageClause.getTypeNodes()) {
-        const typeText = typeNode.getText()
-        const genericCall = this.parseGenericTypeCall(typeText)
-        if (genericCall) {
-          extendedTypes.push(genericCall)
-        } else {
-          extendedTypes.push(ts.factory.createIdentifier(typeText))
-        }
-      }
-    }
+    for (const heritageClause of heritageClauses) {
+      if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) continue
+      for (const typeNode of heritageClause.getTypeNodes()) {
+        extendedTypes.push(this.parseHeritageTypeNode(typeNode))
+      }
+    }

And add the helpers below (see further diffs replacing parseGenericTypeCall/createTypeExpression).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const heritageClause of heritageClauses) {
if (heritageClause.getToken() === ts.SyntaxKind.ExtendsKeyword) {
for (const typeNode of heritageClause.getTypeNodes()) {
// For interface inheritance, we reference the already processed interface by name
const referencedTypeName = typeNode.getText()
extendedTypes.push(ts.factory.createIdentifier(referencedTypeName))
if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) {
continue
}
for (const typeNode of heritageClause.getTypeNodes()) {
const typeText = typeNode.getText()
const genericCall = this.parseGenericTypeCall(typeText)
if (genericCall) {
extendedTypes.push(genericCall)
} else {
extendedTypes.push(ts.factory.createIdentifier(typeText))
}
}
for (const heritageClause of heritageClauses) {
if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) continue
for (const typeNode of heritageClause.getTypeNodes()) {
extendedTypes.push(this.parseHeritageTypeNode(typeNode))
}
}
🤖 Prompt for AI Agents
In src/handlers/typebox/object/interface-type-handler.ts around lines 26 to 39,
replace the fragile getText()/regex-based loop with AST-driven construction: for
each heritageClause.getTypeNodes(), inspect the node kind and build expressions
from the AST (handle Identifier/QualifiedName → Identifier or
PropertyAccessExpression, TypeReference with typeArguments → create a Generic
type expression using the node's typeName and recursively convert each
typeArgument into proper expressions, and handle nested generics/unions/arrays
by recursing into the type nodes), and push the resulting
Expression/ExpressionWithTypeArguments into extendedTypes; remove
parseGenericTypeCall/createTypeExpression usage and add helper(s) that convert
TypeNode/EntityName into the correct
ts.Expression/ts.PropertyAccessExpression/ts.CallExpression equivalents so
qualified names, multiple type args, and nested generics are produced from the
AST rather than from string parsing.

Comment on lines 124 to 151
private parseGenericTypeCall(typeText: string): ts.Expression | null {
const match = typeText.match(/^([^<]+)<([^>]+)>$/)

if (match && match[1] && match[2]) {
const baseName = match[1].trim()
const typeArg = match[2].trim()
return ts.factory.createCallExpression(ts.factory.createIdentifier(baseName), undefined, [
this.createTypeExpression(typeArg),
])
}

return null
}

private createTypeExpression(typeArg: string): ts.Expression {
// Convert common TypeScript types to TypeBox calls
switch (typeArg) {
case 'number':
return makeTypeCall('Number')
case 'string':
return makeTypeCall('String')
case 'boolean':
return makeTypeCall('Boolean')
default:
// For other types, assume it's a reference
return ts.factory.createIdentifier(typeArg)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Regex-based generic parsing produces invalid identifiers and misses common cases; build from AST

The current parseGenericTypeCall/createTypeExpression pair:

  • Fails for nested generics and multiple args.
  • Produces invalid Identifiers for qualified names and for “K, V”.
  • Loses fidelity on non-primitive args.

Switch to AST-driven construction using ExpressionWithTypeArguments, proper property access for qualified names, and multiple type arguments. Also reuse the existing TypeBox type conversion for type arguments.

Apply this diff to replace both helpers with AST-based versions and support qualified names and N-ary generics. This uses ts-morph nodes and defers type-arg conversion to your existing typebox converter (getTypeBoxType). If getTypeBoxType is in a different module, update the import path accordingly.

@@
-import { HeritageClause, InterfaceDeclaration, Node, ts, TypeParameterDeclaration } from 'ts-morph'
+import {
+  HeritageClause,
+  InterfaceDeclaration,
+  Node,
+  ts,
+  TypeParameterDeclaration,
+  ExpressionWithTypeArguments,
+} from 'ts-morph'
+import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call'
@@
-  private parseGenericTypeCall(typeText: string): ts.Expression | null {
-    const match = typeText.match(/^([^<]+)<([^>]+)>$/)
-
-    if (match && match[1] && match[2]) {
-      const baseName = match[1].trim()
-      const typeArg = match[2].trim()
-      return ts.factory.createCallExpression(ts.factory.createIdentifier(baseName), undefined, [
-        this.createTypeExpression(typeArg),
-      ])
-    }
-
-    return null
-  }
+  // Build expression for a heritage type: handles identifiers, ns.qualified identifiers,
+  // and generic calls with any number of type arguments.
+  private parseHeritageTypeNode(node: ExpressionWithTypeArguments): ts.Expression {
+    const exprText = node.getExpression().getText()
+    const baseExpr = this.createQualifiedAccess(exprText)
+    const typeArgs = node.getTypeArguments()
+    if (typeArgs.length === 0) return baseExpr
+    const argExprs = typeArgs.map((arg) => {
+      // Reuse the global TypeBox converter for full fidelity (unions, arrays, nested generics)
+      return getTypeBoxType(arg) as ts.Expression
+    })
+    return ts.factory.createCallExpression(baseExpr, undefined, argExprs)
+  }
@@
-  private createTypeExpression(typeArg: string): ts.Expression {
-    // Convert common TypeScript types to TypeBox calls
-    switch (typeArg) {
-      case 'number':
-        return makeTypeCall('Number')
-      case 'string':
-        return makeTypeCall('String')
-      case 'boolean':
-        return makeTypeCall('Boolean')
-      default:
-        // For other types, assume it's a reference
-        return ts.factory.createIdentifier(typeArg)
-    }
-  }
+  // ns.A.B -> PropertyAccessExpression chain; single segment -> Identifier
+  private createQualifiedAccess(name: string): ts.Expression {
+    const parts = name.split('.').map((p) => p.trim()).filter(Boolean)
+    if (parts.length === 0) return ts.factory.createIdentifier(name)
+    let acc: ts.Expression = ts.factory.createIdentifier(parts[0])
+    for (let i = 1; i < parts.length; i++) {
+      acc = ts.factory.createPropertyAccessExpression(acc, parts[i])
+    }
+    return acc
+  }

If you prefer not to import getTypeBoxType here, we can inject a converter via the base handler or a utility to avoid cross-module dependencies. Let me know and I can adjust the patch.

Comment on lines +4 to +9
import {
InterfaceDeclaration,
ts,
TypeParameterDeclaration,
VariableDeclarationKind,
} from 'ts-morph'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Import structures needed for safe type-parameter emission

You’re constructing ts-morph structures later; pull in OptionalKind and TypeParameterDeclarationStructure so we can pass proper structures instead of pre-printed strings (see fix below).

 import {
   InterfaceDeclaration,
   ts,
-  TypeParameterDeclaration,
+  TypeParameterDeclaration,
+  OptionalKind,
+  TypeParameterDeclarationStructure,
   VariableDeclarationKind,
 } from 'ts-morph'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import {
InterfaceDeclaration,
ts,
TypeParameterDeclaration,
VariableDeclarationKind,
} from 'ts-morph'
// File: src/parsers/parse-interfaces.ts
import {
InterfaceDeclaration,
ts,
TypeParameterDeclaration,
OptionalKind,
TypeParameterDeclarationStructure,
VariableDeclarationKind,
} from 'ts-morph'
🤖 Prompt for AI Agents
In src/parsers/parse-interfaces.ts around lines 4 to 9, the import list is
missing ts-morph helper types required for emitting safe type-parameter
structures; add OptionalKind and TypeParameterDeclarationStructure to the named
imports from 'ts-morph' so later code can pass
OptionalKind<TypeParameterDeclarationStructure> objects instead of pre-printed
strings; update any call sites to build and pass
TypeParameterDeclarationStructure objects (wrapped in OptionalKind) when
creating or setting type parameters.

Comment on lines +102 to +159
private addGenericTypeAlias(
name: string,
typeParameters: TypeParameterDeclaration[],
isExported: boolean,
): void {
// Create type parameters for the type alias
const typeParamDeclarations = typeParameters.map((typeParam) => {
const paramName = typeParam.getName()
return ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier(paramName),
ts.factory.createTypeReferenceNode('TSchema', undefined),
undefined,
)
})

// Create the type: Static<ReturnType<typeof A<T>>>
const typeParamNames = typeParameters.map((tp) => tp.getName())
const typeArguments = typeParamNames.map((paramName) =>
ts.factory.createTypeReferenceNode(paramName, undefined),
)

// Create typeof A<T> expression - we need to create a type reference with type arguments
const typeReferenceWithArgs = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier(name),
typeArguments,
)

const typeofExpression = ts.factory.createTypeQueryNode(
typeReferenceWithArgs.typeName,
typeReferenceWithArgs.typeArguments,
)

const returnTypeExpression = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ReturnType'),
[typeofExpression],
)

const staticTypeNode = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Static'),
[returnTypeExpression],
)

const staticType = this.printer.printNode(
ts.EmitHint.Unspecified,
staticTypeNode,
this.newSourceFile.compilerNode,
)

this.newSourceFile.addTypeAlias({
isExported,
name,
typeParameters: typeParamDeclarations.map((tp) =>
this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode),
),
type: staticType,
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: addTypeAlias receives pre-printed type params; constraints are lost and may generate invalid code

You’re printing ts.TypeParameterDeclaration nodes to strings and passing them to addTypeAlias.typeParameters. ts-morph expects either parameter names or TypeParameterDeclarationStructure[]. Passing "T extends TSchema" as a raw string is treated as a name, not a structured param, which can yield invalid identifiers and drop the extends TSchema constraint.

Fix by passing proper structures and (optionally) typing them with OptionalKind<TypeParameterDeclarationStructure>; also reuse them directly in addTypeAlias.

   private addGenericTypeAlias(
     name: string,
     typeParameters: TypeParameterDeclaration[],
     isExported: boolean,
   ): void {
-    // Create type parameters for the type alias
-    const typeParamDeclarations = typeParameters.map((typeParam) => {
-      const paramName = typeParam.getName()
-      return ts.factory.createTypeParameterDeclaration(
-        undefined,
-        ts.factory.createIdentifier(paramName),
-        ts.factory.createTypeReferenceNode('TSchema', undefined),
-        undefined,
-      )
-    })
+    // Create type parameters for the type alias as ts-morph structures
+    const typeParamStructures: OptionalKind<TypeParameterDeclarationStructure>[] =
+      typeParameters.map((tp) => ({
+        name: tp.getName(),
+        constraint: 'TSchema',
+      }))
 
     // Create the type: Static<ReturnType<typeof A<T>>>
     const typeParamNames = typeParameters.map((tp) => tp.getName())
     const typeArguments = typeParamNames.map((paramName) =>
       ts.factory.createTypeReferenceNode(paramName, undefined),
     )
 
     // Create typeof A<T> expression - we need to create a type reference with type arguments
     const typeReferenceWithArgs = ts.factory.createTypeReferenceNode(
       ts.factory.createIdentifier(name),
       typeArguments,
     )
 
     const typeofExpression = ts.factory.createTypeQueryNode(
       typeReferenceWithArgs.typeName,
       typeReferenceWithArgs.typeArguments,
     )
 
     const returnTypeExpression = ts.factory.createTypeReferenceNode(
       ts.factory.createIdentifier('ReturnType'),
       [typeofExpression],
     )
 
     const staticTypeNode = ts.factory.createTypeReferenceNode(
       ts.factory.createIdentifier('Static'),
       [returnTypeExpression],
     )
 
     const staticType = this.printer.printNode(
       ts.EmitHint.Unspecified,
       staticTypeNode,
       this.newSourceFile.compilerNode,
     )
 
     this.newSourceFile.addTypeAlias({
       isExported,
       name,
-      typeParameters: typeParamDeclarations.map((tp) =>
-        this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode),
-      ),
+      typeParameters: typeParamStructures,
       type: staticType,
     })
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private addGenericTypeAlias(
name: string,
typeParameters: TypeParameterDeclaration[],
isExported: boolean,
): void {
// Create type parameters for the type alias
const typeParamDeclarations = typeParameters.map((typeParam) => {
const paramName = typeParam.getName()
return ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier(paramName),
ts.factory.createTypeReferenceNode('TSchema', undefined),
undefined,
)
})
// Create the type: Static<ReturnType<typeof A<T>>>
const typeParamNames = typeParameters.map((tp) => tp.getName())
const typeArguments = typeParamNames.map((paramName) =>
ts.factory.createTypeReferenceNode(paramName, undefined),
)
// Create typeof A<T> expression - we need to create a type reference with type arguments
const typeReferenceWithArgs = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier(name),
typeArguments,
)
const typeofExpression = ts.factory.createTypeQueryNode(
typeReferenceWithArgs.typeName,
typeReferenceWithArgs.typeArguments,
)
const returnTypeExpression = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ReturnType'),
[typeofExpression],
)
const staticTypeNode = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Static'),
[returnTypeExpression],
)
const staticType = this.printer.printNode(
ts.EmitHint.Unspecified,
staticTypeNode,
this.newSourceFile.compilerNode,
)
this.newSourceFile.addTypeAlias({
isExported,
name,
typeParameters: typeParamDeclarations.map((tp) =>
this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode),
),
type: staticType,
})
}
private addGenericTypeAlias(
name: string,
typeParameters: TypeParameterDeclaration[],
isExported: boolean,
): void {
// Create type parameters for the type alias as ts-morph structures
const typeParamStructures: OptionalKind<TypeParameterDeclarationStructure>[] =
typeParameters.map((tp) => ({
name: tp.getName(),
constraint: 'TSchema',
}))
// Create the type: Static<ReturnType<typeof A<T>>>
const typeParamNames = typeParameters.map((tp) => tp.getName())
const typeArguments = typeParamNames.map((paramName) =>
ts.factory.createTypeReferenceNode(paramName, undefined),
)
// Create typeof A<T> expression - we need to create a type reference with type arguments
const typeReferenceWithArgs = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier(name),
typeArguments,
)
const typeofExpression = ts.factory.createTypeQueryNode(
typeReferenceWithArgs.typeName,
typeReferenceWithArgs.typeArguments,
)
const returnTypeExpression = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('ReturnType'),
[typeofExpression],
)
const staticTypeNode = ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Static'),
[returnTypeExpression],
)
const staticType = this.printer.printNode(
ts.EmitHint.Unspecified,
staticTypeNode,
this.newSourceFile.compilerNode,
)
this.newSourceFile.addTypeAlias({
isExported,
name,
typeParameters: typeParamStructures,
type: staticType,
})
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants