diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 30dacc7..12f1076 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -221,7 +221,7 @@ This directory contains a collection of specialized handler modules, each respon **Object-Like Type Handlers** (extend `ObjectLikeBaseHandler`): - : Handles TypeScript object types and type literals. -- : Handles TypeScript interface declarations, including support for interface inheritance using `Type.Composite` to combine base interfaces with extended properties. +- : 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` to `A(Type.Number())` for proper TypeBox composition. **Collection Type Handlers** (extend `CollectionBaseHandler`): @@ -257,7 +257,7 @@ This directory contains a collection of parser classes, each extending the `Base - : Implements the `ImportParser` class, responsible for resolving and processing TypeScript import declarations. - : Implements the `TypeAliasParser` class, responsible for processing TypeScript `type alias` declarations. - : Implements the `FunctionDeclarationParser` class, responsible for processing TypeScript function declarations and converting them to TypeBox function schemas. -- : Implements the `InterfaceParser` class, responsible for processing TypeScript interface declarations with support for inheritance through dependency ordering and `Type.Composite` generation. +- : 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 diff --git a/src/handlers/typebox/object/interface-type-handler.ts b/src/handlers/typebox/object/interface-type-handler.ts index f9fcca5..91eae19 100644 --- a/src/handlers/typebox/object/interface-type-handler.ts +++ b/src/handlers/typebox/object/interface-type-handler.ts @@ -1,6 +1,6 @@ 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 { @@ -8,24 +8,20 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { } 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 @@ -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 + } } diff --git a/src/parsers/parse-interfaces.ts b/src/parsers/parse-interfaces.ts index 9b5fd99..a6360a3 100644 --- a/src/parsers/parse-interfaces.ts +++ b/src/parsers/parse-interfaces.ts @@ -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' export class InterfaceParser extends BaseParser { parse(interfaceDecl: InterfaceDeclaration): void { @@ -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( @@ -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, @@ -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 = Static>> + 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>> + const typeParamNames = typeParameters.map((tp) => tp.getName()) + const typeArguments = typeParamNames.map((paramName) => + ts.factory.createTypeReferenceNode(paramName, undefined), + ) + + // Create typeof A 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, + }) + } } diff --git a/src/ts-morph-codegen.ts b/src/ts-morph-codegen.ts index 9069886..8105a5c 100644 --- a/src/ts-morph-codegen.ts +++ b/src/ts-morph-codegen.ts @@ -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 = { diff --git a/tests/handlers/typebox/interfaces.test.ts b/tests/handlers/typebox/interfaces.test.ts index a316e59..2be4b04 100644 --- a/tests/handlers/typebox/interfaces.test.ts +++ b/tests/handlers/typebox/interfaces.test.ts @@ -200,5 +200,35 @@ describe('Interfaces', () => { `), ) }) + + test('generic types', () => { + const sourceFile = createSourceFile( + project, + ` + interface A { a: T } + interface B extends A { b: number } + `, + ) + + expect(generateFormattedCode(sourceFile)).resolves.toBe( + formatWithPrettier( + ` + const A = (T: T) => Type.Object({ + a: T + }); + + type A = Static>>; + + const B = Type.Composite([A(Type.Number()), Type.Object({ + b: Type.Number() + })]); + + type B = Static; + `, + true, + true, + ), + ) + }) }) }) diff --git a/tests/utils.ts b/tests/utils.ts index a073aac..4309c56 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,20 +3,30 @@ import synchronizedPrettier from '@prettier/sync' import { Project, SourceFile } from 'ts-morph' const prettierOptions = { parser: 'typescript' as const } -const typeboxImport = 'import { Type, type Static } from "@sinclair/typebox";\n' +const typeboxImport = (withTSchema: boolean) => { + const tschema = withTSchema ? ', type TSchema' : '' + + return `import { Type, type Static${tschema} } from "@sinclair/typebox";\n` +} export const createSourceFile = (project: Project, code: string, filePath: string = 'test.ts') => { return project.createSourceFile(filePath, code) } -export const formatWithPrettier = (input: string, addImport: boolean = true): string => { - const code = addImport ? `${typeboxImport}${input}` : input +export const formatWithPrettier = ( + input: string, + addImport: boolean = true, + withTSchema: boolean = false, +): string => { + const code = addImport ? `${typeboxImport(withTSchema)}${input}` : input + return synchronizedPrettier.format(code, prettierOptions) } export const generateFormattedCode = async ( sourceFile: SourceFile, exportEverything: boolean = false, + withTSchema: boolean = false, ): Promise => { const code = await generateCode({ sourceCode: sourceFile.getFullText(), @@ -24,5 +34,6 @@ export const generateFormattedCode = async ( callerFile: sourceFile.getFilePath(), project: sourceFile.getProject(), }) - return formatWithPrettier(code, false) + + return formatWithPrettier(code, false, withTSchema) }