From 743969615c335ea38883648d4591139c0e732f38 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Tue, 26 Aug 2025 13:06:07 +0200 Subject: [PATCH 1/2] feat(interface-type-handler): Handle generic interfaces 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. --- ARCHITECTURE.md | 4 +- .../typebox/object/interface-type-handler.ts | 125 +++++++++++++++++- src/parsers/parse-interfaces.ts | 110 ++++++++++++++- src/ts-morph-codegen.ts | 28 +++- tests/handlers/typebox/interfaces.test.ts | 30 +++++ tests/utils.ts | 18 ++- 6 files changed, 293 insertions(+), 22 deletions(-) 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..8439c0a 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,9 +8,15 @@ 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 } @@ -18,11 +24,17 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { 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)) + 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)) } } } @@ -36,4 +48,105 @@ 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 + 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), + ]) + } + } + + // 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) + } + } } 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..22000a6 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,20 +3,29 @@ 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 = (tschema: string = '') => + `import { Type, type Static${tschema} } from "@sinclair/typebox";\n` +const typeboxImportTSchema = () => typeboxImport(', type TSchema') 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, + tschema: boolean = false, +): string => { + const importTypebox = tschema ? typeboxImportTSchema() : typeboxImport() + const code = addImport ? `${importTypebox}${input}` : input + return synchronizedPrettier.format(code, prettierOptions) } export const generateFormattedCode = async ( sourceFile: SourceFile, exportEverything: boolean = false, + tschema: boolean = false, ): Promise => { const code = await generateCode({ sourceCode: sourceFile.getFullText(), @@ -24,5 +33,6 @@ export const generateFormattedCode = async ( callerFile: sourceFile.getFilePath(), project: sourceFile.getProject(), }) - return formatWithPrettier(code, false) + + return formatWithPrettier(code, false, tschema) } From dbdad8085d8b37e3d9a04b5847cb97edadb403d6 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Tue, 26 Aug 2025 13:28:01 +0200 Subject: [PATCH 2/2] chore: de-duplicate --- .../typebox/object/interface-type-handler.ts | 69 ++++++++----------- tests/utils.ts | 17 ++--- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/src/handlers/typebox/object/interface-type-handler.ts b/src/handlers/typebox/object/interface-type-handler.ts index 8439c0a..91eae19 100644 --- a/src/handlers/typebox/object/interface-type-handler.ts +++ b/src/handlers/typebox/object/interface-type-handler.ts @@ -21,23 +21,7 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { return baseObjectType } - 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)) - } - } - } + const extendedTypes = this.collectExtendedTypes(heritageClauses) if (extendedTypes.length === 0) { return baseObjectType @@ -72,36 +56,19 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { let functionBody: ts.Expression = baseObjectType // 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)) - } - } - } + const extendedTypes = this.collectExtendedTypes(heritageClauses) - if (extendedTypes.length > 0) { - const allTypes = [...extendedTypes, baseObjectType] - functionBody = makeTypeCall('Composite', [ - ts.factory.createArrayLiteralExpression(allTypes, true), - ]) - } + 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), @@ -127,6 +94,7 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { 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), ]) @@ -149,4 +117,23 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { 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/tests/utils.ts b/tests/utils.ts index 22000a6..4309c56 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,9 +3,11 @@ import synchronizedPrettier from '@prettier/sync' import { Project, SourceFile } from 'ts-morph' const prettierOptions = { parser: 'typescript' as const } -const typeboxImport = (tschema: string = '') => - `import { Type, type Static${tschema} } from "@sinclair/typebox";\n` -const typeboxImportTSchema = () => typeboxImport(', type TSchema') +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) @@ -14,10 +16,9 @@ export const createSourceFile = (project: Project, code: string, filePath: strin export const formatWithPrettier = ( input: string, addImport: boolean = true, - tschema: boolean = false, + withTSchema: boolean = false, ): string => { - const importTypebox = tschema ? typeboxImportTSchema() : typeboxImport() - const code = addImport ? `${importTypebox}${input}` : input + const code = addImport ? `${typeboxImport(withTSchema)}${input}` : input return synchronizedPrettier.format(code, prettierOptions) } @@ -25,7 +26,7 @@ export const formatWithPrettier = ( export const generateFormattedCode = async ( sourceFile: SourceFile, exportEverything: boolean = false, - tschema: boolean = false, + withTSchema: boolean = false, ): Promise => { const code = await generateCode({ sourceCode: sourceFile.getFullText(), @@ -34,5 +35,5 @@ export const generateFormattedCode = async ( project: sourceFile.getProject(), }) - return formatWithPrettier(code, false, tschema) + return formatWithPrettier(code, false, withTSchema) }