Skip to content

Commit c12aa8d

Browse files
authored
feat(interface-type-handler): Handle generic interfaces (#9)
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.
1 parent b3efdca commit c12aa8d

File tree

6 files changed

+287
-28
lines changed

6 files changed

+287
-28
lines changed

ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ This directory contains a collection of specialized handler modules, each respon
221221
**Object-Like Type Handlers** (extend `ObjectLikeBaseHandler`):
222222

223223
- <mcfile name="object-type-handler.ts" path="src/handlers/typebox/object/object-type-handler.ts"></mcfile>: Handles TypeScript object types and type literals.
224-
- <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.
224+
- <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.
225225

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

@@ -257,7 +257,7 @@ This directory contains a collection of parser classes, each extending the `Base
257257
- <mcfile name="parse-imports.ts" path="src/parsers/parse-imports.ts"></mcfile>: Implements the `ImportParser` class, responsible for resolving and processing TypeScript import declarations.
258258
- <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.
259259
- <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.
260-
- <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.
260+
- <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.
261261

262262
### Performance Considerations
263263

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,27 @@
11
import { ObjectLikeBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/object-like-base-handler'
22
import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils'
3-
import { InterfaceDeclaration, Node, ts } from 'ts-morph'
3+
import { HeritageClause, InterfaceDeclaration, Node, ts, TypeParameterDeclaration } from 'ts-morph'
44

55
export class InterfaceTypeHandler extends ObjectLikeBaseHandler {
66
canHandle(node: Node): boolean {
77
return Node.isInterfaceDeclaration(node)
88
}
99

1010
handle(node: InterfaceDeclaration): ts.Expression {
11+
const typeParameters = node.getTypeParameters()
1112
const heritageClauses = node.getHeritageClauses()
1213
const baseObjectType = this.createObjectType(this.processProperties(node.getProperties()))
1314

15+
// If interface has type parameters, generate a function
16+
if (typeParameters.length > 0) {
17+
return this.createGenericInterfaceFunction(typeParameters, baseObjectType, heritageClauses)
18+
}
19+
1420
if (heritageClauses.length === 0) {
1521
return baseObjectType
1622
}
1723

18-
const extendedTypes: ts.Expression[] = []
19-
20-
for (const heritageClause of heritageClauses) {
21-
if (heritageClause.getToken() === ts.SyntaxKind.ExtendsKeyword) {
22-
for (const typeNode of heritageClause.getTypeNodes()) {
23-
// For interface inheritance, we reference the already processed interface by name
24-
const referencedTypeName = typeNode.getText()
25-
extendedTypes.push(ts.factory.createIdentifier(referencedTypeName))
26-
}
27-
}
28-
}
24+
const extendedTypes = this.collectExtendedTypes(heritageClauses)
2925

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

3733
return makeTypeCall('Composite', [ts.factory.createArrayLiteralExpression(allTypes, true)])
3834
}
35+
36+
private createGenericInterfaceFunction(
37+
typeParameters: TypeParameterDeclaration[],
38+
baseObjectType: ts.Expression,
39+
heritageClauses: HeritageClause[],
40+
): ts.Expression {
41+
// Create function parameters for each type parameter
42+
const functionParams = typeParameters.map((typeParam) => {
43+
const paramName = typeParam.getName()
44+
45+
return ts.factory.createParameterDeclaration(
46+
undefined,
47+
undefined,
48+
ts.factory.createIdentifier(paramName),
49+
undefined,
50+
ts.factory.createTypeReferenceNode(paramName, undefined),
51+
undefined,
52+
)
53+
})
54+
55+
// Create function body
56+
let functionBody: ts.Expression = baseObjectType
57+
58+
// Handle heritage clauses for generic interfaces
59+
const extendedTypes = this.collectExtendedTypes(heritageClauses)
60+
61+
if (extendedTypes.length > 0) {
62+
const allTypes = [...extendedTypes, baseObjectType]
63+
functionBody = makeTypeCall('Composite', [
64+
ts.factory.createArrayLiteralExpression(allTypes, true),
65+
])
66+
}
67+
68+
// Create type parameters for the function
69+
const functionTypeParams = typeParameters.map((typeParam) => {
70+
const paramName = typeParam.getName()
71+
72+
return ts.factory.createTypeParameterDeclaration(
73+
undefined,
74+
ts.factory.createIdentifier(paramName),
75+
ts.factory.createTypeReferenceNode('TSchema', undefined),
76+
undefined,
77+
)
78+
})
79+
80+
// Create arrow function
81+
return ts.factory.createArrowFunction(
82+
undefined,
83+
ts.factory.createNodeArray(functionTypeParams),
84+
functionParams,
85+
undefined,
86+
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
87+
functionBody,
88+
)
89+
}
90+
91+
private parseGenericTypeCall(typeText: string): ts.Expression | null {
92+
const match = typeText.match(/^([^<]+)<([^>]+)>$/)
93+
94+
if (match && match[1] && match[2]) {
95+
const baseName = match[1].trim()
96+
const typeArg = match[2].trim()
97+
98+
return ts.factory.createCallExpression(ts.factory.createIdentifier(baseName), undefined, [
99+
this.createTypeExpression(typeArg),
100+
])
101+
}
102+
103+
return null
104+
}
105+
106+
private createTypeExpression(typeArg: string): ts.Expression {
107+
// Convert common TypeScript types to TypeBox calls
108+
switch (typeArg) {
109+
case 'number':
110+
return makeTypeCall('Number')
111+
case 'string':
112+
return makeTypeCall('String')
113+
case 'boolean':
114+
return makeTypeCall('Boolean')
115+
default:
116+
// For other types, assume it's a reference
117+
return ts.factory.createIdentifier(typeArg)
118+
}
119+
}
120+
121+
private collectExtendedTypes(heritageClauses: HeritageClause[]): ts.Expression[] {
122+
const extendedTypes: ts.Expression[] = []
123+
124+
for (const heritageClause of heritageClauses) {
125+
if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) {
126+
continue
127+
}
128+
129+
for (const typeNode of heritageClause.getTypeNodes()) {
130+
const typeText = typeNode.getText()
131+
extendedTypes.push(
132+
this.parseGenericTypeCall(typeText) ?? ts.factory.createIdentifier(typeText),
133+
)
134+
}
135+
}
136+
137+
return extendedTypes
138+
}
39139
}

src/parsers/parse-interfaces.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { BaseParser } from '@daxserver/validation-schema-codegen/parsers/base-parser'
22
import { addStaticTypeAlias } from '@daxserver/validation-schema-codegen/utils/add-static-type-alias'
33
import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call'
4-
import { InterfaceDeclaration, ts, VariableDeclarationKind } from 'ts-morph'
4+
import {
5+
InterfaceDeclaration,
6+
ts,
7+
TypeParameterDeclaration,
8+
VariableDeclarationKind,
9+
} from 'ts-morph'
510

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

2530
this.processedTypes.add(interfaceName)
2631

32+
const typeParameters = interfaceDecl.getTypeParameters()
33+
const isExported = this.getIsExported(interfaceDecl, isImported)
34+
35+
// Check if interface has type parameters (generic)
36+
if (typeParameters.length > 0) {
37+
this.parseGenericInterface(interfaceDecl, isExported)
38+
} else {
39+
this.parseRegularInterface(interfaceDecl, isExported)
40+
}
41+
}
42+
43+
private parseRegularInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
44+
const interfaceName = interfaceDecl.getName()
45+
2746
// Generate TypeBox type definition
2847
const typeboxTypeNode = getTypeBoxType(interfaceDecl)
2948
const typeboxType = this.printer.printNode(
@@ -32,8 +51,6 @@ export class InterfaceParser extends BaseParser {
3251
this.newSourceFile.compilerNode,
3352
)
3453

35-
const isExported = this.getIsExported(interfaceDecl, isImported)
36-
3754
this.newSourceFile.addVariableStatement({
3855
isExported,
3956
declarationKind: VariableDeclarationKind.Const,
@@ -53,4 +70,91 @@ export class InterfaceParser extends BaseParser {
5370
isExported,
5471
)
5572
}
73+
74+
private parseGenericInterface(interfaceDecl: InterfaceDeclaration, isExported: boolean): void {
75+
const interfaceName = interfaceDecl.getName()
76+
const typeParameters = interfaceDecl.getTypeParameters()
77+
78+
// Generate TypeBox function definition
79+
const typeboxTypeNode = getTypeBoxType(interfaceDecl)
80+
const typeboxType = this.printer.printNode(
81+
ts.EmitHint.Expression,
82+
typeboxTypeNode,
83+
this.newSourceFile.compilerNode,
84+
)
85+
86+
// Add the function declaration
87+
this.newSourceFile.addVariableStatement({
88+
isExported,
89+
declarationKind: VariableDeclarationKind.Const,
90+
declarations: [
91+
{
92+
name: interfaceName,
93+
initializer: typeboxType,
94+
},
95+
],
96+
})
97+
98+
// Add generic type alias: type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>
99+
this.addGenericTypeAlias(interfaceName, typeParameters, isExported)
100+
}
101+
102+
private addGenericTypeAlias(
103+
name: string,
104+
typeParameters: TypeParameterDeclaration[],
105+
isExported: boolean,
106+
): void {
107+
// Create type parameters for the type alias
108+
const typeParamDeclarations = typeParameters.map((typeParam) => {
109+
const paramName = typeParam.getName()
110+
return ts.factory.createTypeParameterDeclaration(
111+
undefined,
112+
ts.factory.createIdentifier(paramName),
113+
ts.factory.createTypeReferenceNode('TSchema', undefined),
114+
undefined,
115+
)
116+
})
117+
118+
// Create the type: Static<ReturnType<typeof A<T>>>
119+
const typeParamNames = typeParameters.map((tp) => tp.getName())
120+
const typeArguments = typeParamNames.map((paramName) =>
121+
ts.factory.createTypeReferenceNode(paramName, undefined),
122+
)
123+
124+
// Create typeof A<T> expression - we need to create a type reference with type arguments
125+
const typeReferenceWithArgs = ts.factory.createTypeReferenceNode(
126+
ts.factory.createIdentifier(name),
127+
typeArguments,
128+
)
129+
130+
const typeofExpression = ts.factory.createTypeQueryNode(
131+
typeReferenceWithArgs.typeName,
132+
typeReferenceWithArgs.typeArguments,
133+
)
134+
135+
const returnTypeExpression = ts.factory.createTypeReferenceNode(
136+
ts.factory.createIdentifier('ReturnType'),
137+
[typeofExpression],
138+
)
139+
140+
const staticTypeNode = ts.factory.createTypeReferenceNode(
141+
ts.factory.createIdentifier('Static'),
142+
[returnTypeExpression],
143+
)
144+
145+
const staticType = this.printer.printNode(
146+
ts.EmitHint.Unspecified,
147+
staticTypeNode,
148+
this.newSourceFile.compilerNode,
149+
)
150+
151+
this.newSourceFile.addTypeAlias({
152+
isExported,
153+
name,
154+
typeParameters: typeParamDeclarations.map((tp) =>
155+
this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode),
156+
),
157+
type: staticType,
158+
})
159+
}
56160
}

src/ts-morph-codegen.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,30 @@ export const generateCode = async ({
3030
overwrite: true,
3131
})
3232

33+
// Check if any interfaces have generic type parameters
34+
const hasGenericInterfaces = sourceFile
35+
.getInterfaces()
36+
.some((i) => i.getTypeParameters().length > 0)
37+
3338
// Add imports
39+
const namedImports = [
40+
'Type',
41+
{
42+
name: 'Static',
43+
isTypeOnly: true,
44+
},
45+
]
46+
47+
if (hasGenericInterfaces) {
48+
namedImports.push({
49+
name: 'TSchema',
50+
isTypeOnly: true,
51+
})
52+
}
53+
3454
newSourceFile.addImportDeclaration({
3555
moduleSpecifier: '@sinclair/typebox',
36-
namedImports: [
37-
'Type',
38-
{
39-
name: 'Static',
40-
isTypeOnly: true,
41-
},
42-
],
56+
namedImports,
4357
})
4458

4559
const parserOptions = {

tests/handlers/typebox/interfaces.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,5 +200,35 @@ describe('Interfaces', () => {
200200
`),
201201
)
202202
})
203+
204+
test('generic types', () => {
205+
const sourceFile = createSourceFile(
206+
project,
207+
`
208+
interface A<T> { a: T }
209+
interface B extends A<number> { b: number }
210+
`,
211+
)
212+
213+
expect(generateFormattedCode(sourceFile)).resolves.toBe(
214+
formatWithPrettier(
215+
`
216+
const A = <T extends TSchema>(T: T) => Type.Object({
217+
a: T
218+
});
219+
220+
type A<T extends TSchema> = Static<ReturnType<typeof A<T>>>;
221+
222+
const B = Type.Composite([A(Type.Number()), Type.Object({
223+
b: Type.Number()
224+
})]);
225+
226+
type B = Static<typeof B>;
227+
`,
228+
true,
229+
true,
230+
),
231+
)
232+
})
203233
})
204234
})

0 commit comments

Comments
 (0)