Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ This directory contains a collection of specialized handler modules, each respon
**Object-Like Type Handlers** (extend `ObjectLikeBaseHandler`):

- <mcfile name="object-type-handler.ts" path="src/handlers/typebox/object/object-type-handler.ts"></mcfile>: Handles TypeScript object types and type literals.
- <mcfile name="interface-type-handler.ts" path="src/handlers/typebox/object/interface-type-handler.ts"></mcfile>: Handles TypeScript interface declarations, including support for interface inheritance using `Type.Composite` to combine base interfaces with extended properties.
- <mcfile name="interface-type-handler.ts" path="src/handlers/typebox/object/interface-type-handler.ts"></mcfile>: Handles TypeScript interface declarations, including support for interface inheritance using `Type.Composite` to combine base interfaces with extended properties. Supports generic interfaces with type parameters, generating parameterized functions that accept TypeBox schemas as arguments. Handles generic type calls in heritage clauses, converting expressions like `A<number>` to `A(Type.Number())` for proper TypeBox composition.

**Collection Type Handlers** (extend `CollectionBaseHandler`):

Expand Down Expand Up @@ -257,7 +257,7 @@ This directory contains a collection of parser classes, each extending the `Base
- <mcfile name="parse-imports.ts" path="src/parsers/parse-imports.ts"></mcfile>: Implements the `ImportParser` class, responsible for resolving and processing TypeScript import declarations.
- <mcfile name="parse-type-aliases.ts" path="src/parsers/parse-type-aliases.ts"></mcfile>: Implements the `TypeAliasParser` class, responsible for processing TypeScript `type alias` declarations.
- <mcfile name="parse-function-declarations.ts" path="src/parsers/parse-function-declarations.ts"></mcfile>: Implements the `FunctionDeclarationParser` class, responsible for processing TypeScript function declarations and converting them to TypeBox function schemas.
- <mcfile name="parse-interfaces.ts" path="src/parsers/parse-interfaces.ts"></mcfile>: Implements the `InterfaceParser` class, responsible for processing TypeScript interface declarations with support for inheritance through dependency ordering and `Type.Composite` generation.
- <mcfile name="parse-interfaces.ts" path="src/parsers/parse-interfaces.ts"></mcfile>: Implements the `InterfaceParser` class, responsible for processing TypeScript interface declarations with support for inheritance through dependency ordering and `Type.Composite` generation. Handles generic interfaces by generating parameterized functions with type parameters that accept TypeBox schemas as arguments.

### Performance Considerations

Expand Down
124 changes: 112 additions & 12 deletions src/handlers/typebox/object/interface-type-handler.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
import { ObjectLikeBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/object-like-base-handler'
import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils'
import { InterfaceDeclaration, Node, ts } from 'ts-morph'
import { HeritageClause, InterfaceDeclaration, Node, ts, TypeParameterDeclaration } from 'ts-morph'

export class InterfaceTypeHandler extends ObjectLikeBaseHandler {
canHandle(node: Node): boolean {
return Node.isInterfaceDeclaration(node)
}

handle(node: InterfaceDeclaration): ts.Expression {
const typeParameters = node.getTypeParameters()
const heritageClauses = node.getHeritageClauses()
const baseObjectType = this.createObjectType(this.processProperties(node.getProperties()))

// If interface has type parameters, generate a function
if (typeParameters.length > 0) {
return this.createGenericInterfaceFunction(typeParameters, baseObjectType, heritageClauses)
}

if (heritageClauses.length === 0) {
return baseObjectType
}

const extendedTypes: ts.Expression[] = []

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))
}
}
}
const extendedTypes = this.collectExtendedTypes(heritageClauses)

if (extendedTypes.length === 0) {
return baseObjectType
Expand All @@ -36,4 +32,108 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler {

return makeTypeCall('Composite', [ts.factory.createArrayLiteralExpression(allTypes, true)])
}

private createGenericInterfaceFunction(
typeParameters: TypeParameterDeclaration[],
baseObjectType: ts.Expression,
heritageClauses: HeritageClause[],
): ts.Expression {
// Create function parameters for each type parameter
const functionParams = typeParameters.map((typeParam) => {
const paramName = typeParam.getName()

return ts.factory.createParameterDeclaration(
undefined,
undefined,
ts.factory.createIdentifier(paramName),
undefined,
ts.factory.createTypeReferenceNode(paramName, undefined),
undefined,
)
})

// Create function body
let functionBody: ts.Expression = baseObjectType

// Handle heritage clauses for generic interfaces
const extendedTypes = this.collectExtendedTypes(heritageClauses)

if (extendedTypes.length > 0) {
const allTypes = [...extendedTypes, baseObjectType]
functionBody = makeTypeCall('Composite', [
ts.factory.createArrayLiteralExpression(allTypes, true),
])
}

// Create type parameters for the function
const functionTypeParams = typeParameters.map((typeParam) => {
const paramName = typeParam.getName()

return ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier(paramName),
ts.factory.createTypeReferenceNode('TSchema', undefined),
undefined,
)
})

// Create arrow function
return ts.factory.createArrowFunction(
undefined,
ts.factory.createNodeArray(functionTypeParams),
functionParams,
undefined,
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
functionBody,
)
}

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)
}
}

private collectExtendedTypes(heritageClauses: HeritageClause[]): ts.Expression[] {
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()
extendedTypes.push(
this.parseGenericTypeCall(typeText) ?? ts.factory.createIdentifier(typeText),
)
}
}

return extendedTypes
}
}
110 changes: 107 additions & 3 deletions src/parsers/parse-interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { BaseParser } from '@daxserver/validation-schema-codegen/parsers/base-parser'
import { addStaticTypeAlias } from '@daxserver/validation-schema-codegen/utils/add-static-type-alias'
import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call'
import { InterfaceDeclaration, ts, VariableDeclarationKind } from 'ts-morph'
import {
InterfaceDeclaration,
ts,
TypeParameterDeclaration,
VariableDeclarationKind,
} from 'ts-morph'
Comment on lines +4 to +9
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.


export class InterfaceParser extends BaseParser {
parse(interfaceDecl: InterfaceDeclaration): void {
Expand All @@ -24,6 +29,20 @@ export class InterfaceParser extends BaseParser {

this.processedTypes.add(interfaceName)

const typeParameters = interfaceDecl.getTypeParameters()
const isExported = this.getIsExported(interfaceDecl, isImported)

// Check if interface has type parameters (generic)
if (typeParameters.length > 0) {
this.parseGenericInterface(interfaceDecl, isExported)
} else {
this.parseRegularInterface(interfaceDecl, isExported)
}
}

private parseRegularInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
const interfaceName = interfaceDecl.getName()

// Generate TypeBox type definition
const typeboxTypeNode = getTypeBoxType(interfaceDecl)
const typeboxType = this.printer.printNode(
Expand All @@ -32,8 +51,6 @@ export class InterfaceParser extends BaseParser {
this.newSourceFile.compilerNode,
)

const isExported = this.getIsExported(interfaceDecl, isImported)

this.newSourceFile.addVariableStatement({
isExported,
declarationKind: VariableDeclarationKind.Const,
Expand All @@ -53,4 +70,91 @@ export class InterfaceParser extends BaseParser {
isExported,
)
}

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

// Generate TypeBox function definition
const typeboxTypeNode = getTypeBoxType(interfaceDecl)
const typeboxType = this.printer.printNode(
ts.EmitHint.Expression,
typeboxTypeNode,
this.newSourceFile.compilerNode,
)

// Add the function declaration
this.newSourceFile.addVariableStatement({
isExported,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: interfaceName,
initializer: typeboxType,
},
],
})

// Add generic type alias: type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>
this.addGenericTypeAlias(interfaceName, typeParameters, isExported)
}

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,
})
}
Comment on lines +102 to +159
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,
})
}

}
28 changes: 21 additions & 7 deletions src/ts-morph-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,30 @@ export const generateCode = async ({
overwrite: true,
})

// Check if any interfaces have generic type parameters
const hasGenericInterfaces = sourceFile
.getInterfaces()
.some((i) => i.getTypeParameters().length > 0)

// Add imports
const namedImports = [
'Type',
{
name: 'Static',
isTypeOnly: true,
},
]

if (hasGenericInterfaces) {
namedImports.push({
name: 'TSchema',
isTypeOnly: true,
})
}

newSourceFile.addImportDeclaration({
moduleSpecifier: '@sinclair/typebox',
namedImports: [
'Type',
{
name: 'Static',
isTypeOnly: true,
},
],
namedImports,
})

const parserOptions = {
Expand Down
30 changes: 30 additions & 0 deletions tests/handlers/typebox/interfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,5 +200,35 @@ describe('Interfaces', () => {
`),
)
})

test('generic types', () => {
const sourceFile = createSourceFile(
project,
`
interface A<T> { a: T }
interface B extends A<number> { b: number }
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(
`
const A = <T extends TSchema>(T: T) => Type.Object({
a: T
});

type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>;

const B = Type.Composite([A(Type.Number()), Type.Object({
b: Type.Number()
})]);

type B = Static<typeof B>;
`,
true,
true,
),
)
})
})
})
Loading