diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9fa1c98..a1df5d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,9 +24,9 @@ ### Supported TypeScript Constructs - **Type Definitions**: Type aliases, interfaces, enums, and function declarations -- **Generic Types**: Generic interfaces and type parameters with proper constraint handling +- **Generic Types**: Generic interfaces and type aliases with type parameters and proper constraint handling - **Complex Types**: Union and intersection types, nested object structures, template literal types -- **Utility Types**: Built-in support for Pick, Omit, Partial, Required, Record, and other TypeScript utility types +- **Utility Types**: Built-in support for Pick, Omit, Partial, Required, Record, Readonly, and other TypeScript utility types - **Advanced Features**: Conditional types, mapped types, keyof operators, indexed access types - **Import Resolution**: Cross-file type dependencies with qualified naming and circular dependency handling @@ -39,7 +39,7 @@ The main logic for code generation resides in the orchestrates the entire code generation process: 1. **Input Processing**: Creates a `SourceFile` from input using `createSourceFileFromInput` -2. **Generic Interface Detection**: Checks for generic interfaces to determine required TypeBox imports +2. **Generic Type Detection**: Checks for generic interfaces and type aliases to determine required TypeBox imports (including `TSchema`) 3. **Output File Creation**: Creates a new output file with necessary `@sinclair/typebox` imports using `createOutputFile` 4. **Dependency Traversal**: Uses `DependencyTraversal` to analyze and sort all type dependencies 5. **Code Generation**: Processes sorted nodes using `TypeBoxPrinter` in `printSortedNodes` @@ -113,8 +113,8 @@ The pr #### Specialized Parsers -1. **InterfaceParser**: - Handles both regular and generic interfaces -2. **TypeAliasParser**: - Processes type alias declarations +1. **InterfaceParser**: - Handles both regular and generic interfaces using the unified `GenericTypeUtils` flow for consistency with type aliases +2. **TypeAliasParser**: - Processes both regular and generic type alias declarations using `GenericTypeUtils.createGenericArrowFunction` 3. **EnumParser**: - Handles enum declarations 4. **FunctionParser**: - Processes function declarations 5. **ImportParser**: - Handles import resolution @@ -127,20 +127,42 @@ The handler system in and specialized base classes 2. **Collection Handlers**: , , , -3. **Object Handlers**: , +3. **Object Handlers**: (returns raw TypeBox expressions for generic interfaces, allowing parser-level arrow function wrapping), 4. **Reference Handlers**: , , , , -5. **Simple Handlers**: , -6. **Advanced Handlers**: , , , -7. **Function Handlers**: -8. **Type Query Handlers**: , -9. **Access Handlers**: , +5. **Readonly Handlers**: , +6. **Simple Handlers**: , +7. **Advanced Handlers**: , , +8. **Function Handlers**: +9. **Type Query Handlers**: , +10. **Access Handlers**: , + +#### Readonly Type Handling + +The system provides comprehensive support for TypeScript's two distinct readonly constructs through a dual-handler approach: + +1. **Readonly Utility Type**: `Readonly` - Handled by + - Registered as a type reference handler for `Readonly` type references + - Processes `TypeReferenceNode` with identifier "Readonly" + - Generates `Type.Readonly(innerType)` for utility type syntax + +2. **Readonly Array Modifier**: `readonly T[]` - Handled by + - Extends `TypeOperatorBaseHandler` for `ReadonlyKeyword` operator + - Processes `TypeOperatorTypeNode` with `SyntaxKind.ReadonlyKeyword` + - Generates `Type.Readonly(innerType)` for array modifier syntax + - Registered as a fallback handler to handle complex readonly patterns + +This dual approach ensures proper handling of both TypeScript readonly constructs: + +- `type ReadonlyUser = Readonly` (utility type) +- `type ReadonlyArray = readonly string[]` (array modifier) +- `type ReadonlyTuple = readonly [string, number]` (tuple modifier) #### Handler Management The class orchestrates all handlers through: - **Handler Caching**: Caches handler instances for performance optimization -- **Fallback System**: Provides fallback handlers for complex cases +- **Fallback System**: Provides fallback handlers for complex cases including readonly array modifiers ### Import Resolution @@ -181,6 +203,39 @@ export interface InputOptions { - **Source Code Input**: Processes TypeScript code directly from strings with validation - **Project Context**: Enables proper relative import resolution when working with in-memory source files +### Generic Type Support + +The codebase provides comprehensive support for both generic interfaces and generic type aliases, enabling complex type transformations and reusable type definitions. + +#### Generic Type Aliases + +The `TypeAliasParser` handles both regular and generic type aliases through specialized processing: + +1. **Type Parameter Detection**: Automatically detects type parameters using `typeAlias.getTypeParameters()` +2. **Function Generation**: Creates TypeBox functions for generic type aliases with proper parameter constraints +3. **TSchema Constraints**: Applies `TSchema` constraints to all type parameters for TypeBox compatibility +4. **Static Type Generation**: Generates corresponding TypeScript type aliases using `Static>>` + +#### Generic Interface Support + +Generic interfaces are processed through the `InterfaceParser` using a consistent architectural pattern that mirrors the type alias flow: + +1. **Unified Generic Processing**: The interface parser now uses the same `GenericTypeUtils.createGenericArrowFunction` flow as type aliases for consistency +2. **Raw Expression Handling**: The `InterfaceTypeHandler` returns raw TypeBox expressions for generic interfaces, allowing the parser to handle arrow function wrapping +3. **Parameter Constraint Handling**: Converts TypeScript type parameter constraints to `TSchema` constraints using shared utilities +4. **Function-Based Schema Generation**: Creates TypeBox schema functions that accept type parameters through the standardized generic arrow function pattern +5. **Type Safety Preservation**: Maintains full TypeScript type safety through proper static type aliases using `Static>>` +6. **Architectural Consistency**: Both generic interfaces and type aliases now follow the same code generation pattern, improving maintainability and reducing duplication + +#### Complex Generic Scenarios + +The system supports advanced generic patterns including: + +- **Multiple Type Parameters**: Functions with multiple generic parameters (e.g., `ApiResponse`) +- **Nested Generic Types**: Generic types that reference other generic types +- **Utility Type Combinations**: Complex combinations like `Partial>>` +- **Type Parameter Propagation**: Proper handling of type parameters across nested type references + ### Interface Inheritance The codebase provides comprehensive support for TypeScript interface inheritance through a sophisticated dependency resolution and code generation system: @@ -234,6 +289,16 @@ The directory provides essential - **TypeBox Expression Generation**: Converts extracted keys into appropriate TypeBox array expressions - **Shared Utilities**: Provides reusable key extraction logic for Pick, Omit, and other utility type handlers to avoid code duplication +#### Generic Type Utilities + +The module provides shared utilities for consistent generic type handling across parsers: + +- **Generic Arrow Function Creation**: `createGenericArrowFunction` creates standardized arrow functions for generic types with proper type parameter constraints +- **Type Parameter Processing**: Converts TypeScript type parameters to TypeBox-compatible function parameters with `TSchema` constraints +- **Variable Statement Generation**: `addTypeBoxVariableStatement` creates consistent variable declarations for TypeBox schemas +- **Generic Type Alias Generation**: `addGenericTypeAlias` creates standardized static type aliases using `Static>>` +- **Architectural Consistency**: Ensures both interface and type alias parsers follow the same generic type processing pattern + ## Process Overview 1. **Input**: A TypeScript source file containing `enum`, `type alias`, `interface`, and `function` declarations. diff --git a/src/handlers/typebox/object/interface-type-handler.ts b/src/handlers/typebox/object/interface-type-handler.ts index 8603c57..dfc4d02 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 { HeritageClause, InterfaceDeclaration, Node, ts, TypeParameterDeclaration } from 'ts-morph' +import { HeritageClause, InterfaceDeclaration, Node, ts } from 'ts-morph' export class InterfaceTypeHandler extends ObjectLikeBaseHandler { canHandle(node: Node): boolean { @@ -12,11 +12,26 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { const heritageClauses = node.getHeritageClauses() const baseObjectType = this.createObjectType(this.processProperties(node.getProperties())) - // If interface has type parameters, generate a function + // For generic interfaces, return raw TypeBox expression + // The parser will handle wrapping it in an arrow function using GenericTypeUtils if (typeParameters.length > 0) { - return this.createGenericInterfaceFunction(typeParameters, baseObjectType, heritageClauses) + // For generic interfaces, handle inheritance here and return raw expression + if (heritageClauses.length === 0) { + return baseObjectType + } + + const extendedTypes = this.collectExtendedTypes(heritageClauses) + + if (extendedTypes.length === 0) { + return baseObjectType + } + + // Create composite with extended types first, then the current interface + const allTypes = [...extendedTypes, baseObjectType] + return makeTypeCall('Composite', [ts.factory.createArrayLiteralExpression(allTypes, true)]) } + // For non-generic interfaces, handle as before if (heritageClauses.length === 0) { return baseObjectType } @@ -33,64 +48,6 @@ 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() - - // Use TSchema as the constraint for TypeBox compatibility - const constraintNode = ts.factory.createTypeReferenceNode('TSchema', undefined) - - return ts.factory.createTypeParameterDeclaration( - undefined, - ts.factory.createIdentifier(paramName), - constraintNode, - 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(/^([^<]+)<([^>]+)>$/) diff --git a/src/handlers/typebox/readonly-array-type-handler.ts b/src/handlers/typebox/readonly-array-type-handler.ts new file mode 100644 index 0000000..c09aeda --- /dev/null +++ b/src/handlers/typebox/readonly-array-type-handler.ts @@ -0,0 +1,12 @@ +import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-operator-base-handler' +import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' +import { SyntaxKind, ts } from 'ts-morph' + +export class ReadonlyArrayTypeHandler extends TypeOperatorBaseHandler { + protected readonly operatorKind = SyntaxKind.ReadonlyKeyword + protected readonly typeBoxMethod = 'Readonly' + + protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { + return makeTypeCall('Readonly', [innerType]) + } +} diff --git a/src/handlers/typebox/readonly-type-handler.ts b/src/handlers/typebox/readonly-type-handler.ts index 745416c..e5b237b 100644 --- a/src/handlers/typebox/readonly-type-handler.ts +++ b/src/handlers/typebox/readonly-type-handler.ts @@ -1,12 +1,16 @@ -import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-operator-base-handler' +import { TypeReferenceBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/type-reference-base-handler' +import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { SyntaxKind, ts } from 'ts-morph' +import { Node, ts } from 'ts-morph' -export class ReadonlyTypeHandler extends TypeOperatorBaseHandler { - protected readonly operatorKind = SyntaxKind.ReadonlyKeyword - protected readonly typeBoxMethod = 'Readonly' +export class ReadonlyTypeHandler extends TypeReferenceBaseHandler { + protected readonly supportedTypeNames = ['Readonly'] + protected readonly expectedArgumentCount = 1 - protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { - return makeTypeCall('Readonly', [innerType]) + handle(node: Node): ts.Expression { + const typeRef = this.validateTypeReference(node) + const [innerType] = this.extractTypeArguments(typeRef) + + return makeTypeCall('Readonly', [getTypeBoxType(innerType)]) } } diff --git a/src/handlers/typebox/typebox-type-handlers.ts b/src/handlers/typebox/typebox-type-handlers.ts index 89bc719..c96f603 100644 --- a/src/handlers/typebox/typebox-type-handlers.ts +++ b/src/handlers/typebox/typebox-type-handlers.ts @@ -9,6 +9,7 @@ import { KeyOfTypeHandler } from '@daxserver/validation-schema-codegen/handlers/ import { LiteralTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/literal-type-handler' import { InterfaceTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/interface-type-handler' import { ObjectTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/object-type-handler' +import { ReadonlyArrayTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/readonly-array-type-handler' import { ReadonlyTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/readonly-type-handler' import { OmitTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/omit-type-handler' import { PartialTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/partial-type-handler' @@ -50,6 +51,7 @@ export class TypeBoxTypeHandlers { const templateLiteralTypeHandler = new TemplateLiteralTypeHandler() const typeofTypeHandler = new TypeofTypeHandler() const readonlyTypeHandler = new ReadonlyTypeHandler() + const readonlyArrayTypeHandler = new ReadonlyArrayTypeHandler() // O(1) lookup by SyntaxKind this.syntaxKindHandlers.set(SyntaxKind.AnyKeyword, simpleTypeHandler) @@ -80,13 +82,14 @@ export class TypeBoxTypeHandlers { this.typeReferenceHandlers.set('Pick', pickTypeHandler) this.typeReferenceHandlers.set('Omit', omitTypeHandler) this.typeReferenceHandlers.set('Required', requiredTypeHandler) + this.typeReferenceHandlers.set('Readonly', readonlyTypeHandler) // Fallback handlers for complex cases this.fallbackHandlers = [ typeReferenceHandler, keyOfTypeHandler, typeofTypeHandler, - readonlyTypeHandler, + readonlyArrayTypeHandler, ] } diff --git a/src/index.ts b/src/index.ts index ea4644e..c7c2d6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,8 +14,11 @@ const createOutputFile = (hasGenericInterfaces: boolean) => { }) // Add imports - const namedImports = [ - 'Type', + const namedImports: { name: string; isTypeOnly: boolean }[] = [ + { + name: 'Type', + isTypeOnly: false, + }, { name: 'Static', isTypeOnly: true, @@ -75,9 +78,11 @@ export const generateCode = (options: InputOptions): string => { const dependencyTraversal = new DependencyTraversal() const traversedNodes = dependencyTraversal.startTraversal(sourceFile) - // Check if any interfaces have generic type parameters + // Check if any interfaces or type aliases have generic type parameters const hasGenericInterfaces = traversedNodes.some( - (t) => Node.isInterfaceDeclaration(t.node) && t.node.getTypeParameters().length > 0, + (t) => + (Node.isInterfaceDeclaration(t.node) && t.node.getTypeParameters().length > 0) || + (Node.isTypeAliasDeclaration(t.node) && t.node.getTypeParameters().length > 0), ) // Create output file with proper imports diff --git a/src/parsers/parse-interfaces.ts b/src/parsers/parse-interfaces.ts index 46a77e2..4143629 100644 --- a/src/parsers/parse-interfaces.ts +++ b/src/parsers/parse-interfaces.ts @@ -1,12 +1,8 @@ import { BaseParser } from '@daxserver/validation-schema-codegen/parsers/base-parser' import { addStaticTypeAlias } from '@daxserver/validation-schema-codegen/utils/add-static-type-alias' +import { GenericTypeUtils } from '@daxserver/validation-schema-codegen/utils/generic-type-utils' import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' -import { - InterfaceDeclaration, - ts, - TypeParameterDeclaration, - VariableDeclarationKind, -} from 'ts-morph' +import { InterfaceDeclaration, ts } from 'ts-morph' export class InterfaceParser extends BaseParser { parse(interfaceDecl: InterfaceDeclaration): void { @@ -39,16 +35,7 @@ export class InterfaceParser extends BaseParser { this.newSourceFile.compilerNode, ) - this.newSourceFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: interfaceName, - initializer: typeboxType, - }, - ], - }) + GenericTypeUtils.addTypeBoxVariableStatement(this.newSourceFile, interfaceName, typeboxType) addStaticTypeAlias( this.newSourceFile, @@ -62,85 +49,34 @@ export class InterfaceParser extends BaseParser { const interfaceName = interfaceDecl.getName() const typeParameters = interfaceDecl.getTypeParameters() - // Generate TypeBox function definition + // Generate TypeBox function definition using the same flow as type aliases const typeboxTypeNode = getTypeBoxType(interfaceDecl) - const typeboxType = this.printer.printNode( - ts.EmitHint.Expression, - typeboxTypeNode, - this.newSourceFile.compilerNode, - ) - - // Add the function declaration - this.newSourceFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: interfaceName, - initializer: typeboxType, - }, - ], - }) - - // Add generic type alias: type A = Static>> - this.addGenericTypeAlias(interfaceName, typeParameters) - } - - private addGenericTypeAlias(name: string, typeParameters: TypeParameterDeclaration[]): void { - // Create type parameters for the type alias - const typeParamDeclarations = typeParameters.map((typeParam) => { - const paramName = typeParam.getName() - // Use TSchema as the constraint for TypeBox compatibility - const constraintNode = ts.factory.createTypeReferenceNode('TSchema', undefined) - - return ts.factory.createTypeParameterDeclaration( - undefined, - ts.factory.createIdentifier(paramName), - constraintNode, - 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, + // Create the function expression using shared utilities (mirrors type-alias flow) + const functionExpression = GenericTypeUtils.createGenericArrowFunction( + typeParameters, + typeboxTypeNode, ) - const returnTypeExpression = ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ReturnType'), - [typeofExpression], + const functionExpressionText = this.printer.printNode( + ts.EmitHint.Expression, + functionExpression, + this.newSourceFile.compilerNode, ) - const staticTypeNode = ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Static'), - [returnTypeExpression], + // Add the function declaration + GenericTypeUtils.addTypeBoxVariableStatement( + this.newSourceFile, + interfaceName, + functionExpressionText, ) - const staticType = this.printer.printNode( - ts.EmitHint.Unspecified, - staticTypeNode, - this.newSourceFile.compilerNode, + // Add generic type alias using shared utility + GenericTypeUtils.addGenericTypeAlias( + this.newSourceFile, + interfaceName, + typeParameters, + this.printer, ) - - this.newSourceFile.addTypeAlias({ - isExported: true, - name, - typeParameters: typeParamDeclarations.map((tp) => - this.printer.printNode(ts.EmitHint.Unspecified, tp, this.newSourceFile.compilerNode), - ), - type: staticType, - }) } } diff --git a/src/parsers/parse-type-aliases.ts b/src/parsers/parse-type-aliases.ts index 9fd5695..0c7fe74 100644 --- a/src/parsers/parse-type-aliases.ts +++ b/src/parsers/parse-type-aliases.ts @@ -1,15 +1,31 @@ import { BaseParser } from '@daxserver/validation-schema-codegen/parsers/base-parser' import { addStaticTypeAlias } from '@daxserver/validation-schema-codegen/utils/add-static-type-alias' +import { GenericTypeUtils } from '@daxserver/validation-schema-codegen/utils/generic-type-utils' import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { ts, TypeAliasDeclaration, VariableDeclarationKind } from 'ts-morph' +import { ts, TypeAliasDeclaration } from 'ts-morph' export class TypeAliasParser extends BaseParser { parse(typeAlias: TypeAliasDeclaration): void { - this.parseWithImportFlag(typeAlias) + const typeName = typeAlias.getName() + + if (this.processedTypes.has(typeName)) { + return + } + + this.processedTypes.add(typeName) + + const typeParameters = typeAlias.getTypeParameters() + + // Check if type alias has type parameters (generic) + if (typeParameters.length > 0) { + this.parseGenericTypeAlias(typeAlias) + } else { + this.parseRegularTypeAlias(typeAlias) + } } - parseWithImportFlag(typeAlias: TypeAliasDeclaration): void { + private parseRegularTypeAlias(typeAlias: TypeAliasDeclaration): void { const typeName = typeAlias.getName() const typeNode = typeAlias.getTypeNode() @@ -20,17 +36,39 @@ export class TypeAliasParser extends BaseParser { this.newSourceFile.compilerNode, ) - this.newSourceFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: typeName, - initializer: typeboxType, - }, - ], - }) + GenericTypeUtils.addTypeBoxVariableStatement(this.newSourceFile, typeName, typeboxType) addStaticTypeAlias(this.newSourceFile, typeName, this.newSourceFile.compilerNode, this.printer) } + + private parseGenericTypeAlias(typeAlias: TypeAliasDeclaration): void { + const typeName = typeAlias.getName() + const typeParameters = typeAlias.getTypeParameters() + + // Generate TypeBox function definition + const typeNode = typeAlias.getTypeNode() + const typeboxTypeNode = typeNode ? getTypeBoxType(typeNode) : makeTypeCall('Any') + + // Create the function expression using shared utilities + const functionExpression = GenericTypeUtils.createGenericArrowFunction( + typeParameters, + typeboxTypeNode, + ) + + const functionExpressionText = this.printer.printNode( + ts.EmitHint.Expression, + functionExpression, + this.newSourceFile.compilerNode, + ) + + // Add the function declaration + GenericTypeUtils.addTypeBoxVariableStatement( + this.newSourceFile, + typeName, + functionExpressionText, + ) + + // Add generic type alias using shared utility + GenericTypeUtils.addGenericTypeAlias(this.newSourceFile, typeName, typeParameters, this.printer) + } } diff --git a/src/printer/typebox-printer.ts b/src/printer/typebox-printer.ts index 034fb55..e935fc1 100644 --- a/src/printer/typebox-printer.ts +++ b/src/printer/typebox-printer.ts @@ -41,7 +41,7 @@ export class TypeBoxPrinter { switch (true) { case Node.isTypeAliasDeclaration(node): - this.typeAliasParser.parseWithImportFlag(node) + this.typeAliasParser.parse(node) break case Node.isInterfaceDeclaration(node): diff --git a/src/utils/generic-type-utils.ts b/src/utils/generic-type-utils.ts new file mode 100644 index 0000000..6a4ea9d --- /dev/null +++ b/src/utils/generic-type-utils.ts @@ -0,0 +1,148 @@ +import { SourceFile, ts, TypeParameterDeclaration, VariableDeclarationKind } from 'ts-morph' + +/** + * Utility functions for handling generic types in parsers + */ +export class GenericTypeUtils { + /** + * Adds a TypeBox variable statement to the source file + */ + static addTypeBoxVariableStatement( + newSourceFile: SourceFile, + name: string, + initializer: string, + ): void { + newSourceFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name, + initializer, + }, + ], + }) + } + /** + * Creates function parameters for generic type parameters + */ + static createFunctionParameters( + typeParameters: TypeParameterDeclaration[], + ): ts.ParameterDeclaration[] { + return typeParameters.map((typeParam) => { + const paramName = typeParam.getName() + + return ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier(paramName), + undefined, + ts.factory.createTypeReferenceNode(paramName), + ) + }) + } + + /** + * Creates type parameters with TSchema constraints for generic functions + */ + static createFunctionTypeParameters( + typeParameters: TypeParameterDeclaration[], + ): ts.TypeParameterDeclaration[] { + return typeParameters.map((typeParam) => { + const paramName = typeParam.getName() + const constraintNode = ts.factory.createTypeReferenceNode('TSchema') + + return ts.factory.createTypeParameterDeclaration( + undefined, + ts.factory.createIdentifier(paramName), + constraintNode, + ) + }) + } + + /** + * Creates an arrow function for generic types + */ + static createGenericArrowFunction( + typeParameters: TypeParameterDeclaration[], + functionBody: ts.Expression, + ): ts.Expression { + const functionParams = this.createFunctionParameters(typeParameters) + const functionTypeParams = this.createFunctionTypeParameters(typeParameters) + + return ts.factory.createArrowFunction( + undefined, + functionTypeParams, + functionParams, + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + functionBody, + ) + } + + /** + * Adds a generic type alias to the source file + * Generates: export type TypeName = Static>> + */ + static addGenericTypeAlias( + newSourceFile: SourceFile, + name: string, + typeParameters: TypeParameterDeclaration[], + printer: ts.Printer, + ): void { + // Create type parameters for the type alias + const typeParamDeclarations = typeParameters.map((typeParam) => { + const paramName = typeParam.getName() + // Use TSchema as the constraint for TypeBox compatibility + const constraintNode = ts.factory.createTypeReferenceNode('TSchema') + + return ts.factory.createTypeParameterDeclaration( + undefined, + ts.factory.createIdentifier(paramName), + constraintNode, + ) + }) + + // Create the type: Static>> + const typeParamNames = typeParameters.map((tp) => tp.getName()) + const typeArguments = typeParamNames.map((paramName) => + ts.factory.createTypeReferenceNode(paramName), + ) + + // 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 = printer.printNode( + ts.EmitHint.Unspecified, + staticTypeNode, + newSourceFile.compilerNode, + ) + + newSourceFile.addTypeAlias({ + isExported: true, + name, + typeParameters: typeParamDeclarations.map((tp) => + printer.printNode(ts.EmitHint.Unspecified, tp, newSourceFile.compilerNode), + ), + type: staticType, + }) + } +} diff --git a/src/utils/key-extraction-utils.ts b/src/utils/key-extraction-utils.ts index 01109d7..94d896f 100644 --- a/src/utils/key-extraction-utils.ts +++ b/src/utils/key-extraction-utils.ts @@ -39,7 +39,6 @@ export const createTypeBoxKeys = (keys: string[]): ts.Expression => { return makeTypeCall('Union', [ ts.factory.createArrayLiteralExpression( keys.map((k) => makeTypeCall('Literal', [ts.factory.createStringLiteral(k)])), - false, ), ]) } diff --git a/tests/handlers/typebox/generics.test.ts b/tests/handlers/typebox/generics.test.ts new file mode 100644 index 0000000..cd1becd --- /dev/null +++ b/tests/handlers/typebox/generics.test.ts @@ -0,0 +1,170 @@ +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('generic types', () => { + let project: Project + + beforeEach(() => { + project = new Project() + }) + + test('generic types', () => { + const sourceFile = createSourceFile( + project, + ` + interface A { a: T } + interface B extends A { b: number } + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const A = (T: T) => Type.Object({ + a: T + }); + + export type A = Static>>; + + export const B = Type.Composite([A(Type.Number()), Type.Object({ + b: Type.Number() + })]); + + export type B = Static; + `, + true, + true, + ), + ) + }) + + test('generic types extension', () => { + const sourceFile = createSourceFile( + project, + ` + interface A { a: T } + interface B extends A { b: T } + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const A = (T: T) => Type.Object({ + a: T + }); + + export type A = Static>>; + + export const B = (T: T) => Type.Composite([A(T), Type.Object({ + b: T + })]); + + export type B = Static>>; + `, + true, + true, + ), + ) + }) + + test('generic types with extended type', () => { + const sourceFile = createSourceFile( + project, + ` + declare const A: readonly ["a", "b"] + type A = typeof A[number] + interface B { a: T } + type C = B<'a'> + type D = B<'b'> + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const A = Type.Union([Type.Literal('a'), Type.Literal('b')]) + + export type A = Static + + export const B = (T: T) => Type.Object({ + a: T + }) + + export type B = Static>> + + export const C = B(Type.Literal('a')) + + export type C = Static + + export const D = B(Type.Literal('b')) + + export type D = Static + `, + true, + true, + ), + ) + }) + + test('generics with complexity', () => { + const sourceFile = createSourceFile( + project, + ` + export type LanguageCode = string; + type LanguageRecord = Partial>>; + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const LanguageCode = Type.String() + + export type LanguageCode = Static + + export const LanguageRecord = (V: V) => Type.Partial(Type.Readonly(Type.Record(LanguageCode, V))) + + export type LanguageRecord = Static>> + `, + true, + true, + ), + ) + }) + + test('multiple generic parameters with constraints', () => { + const sourceFile = createSourceFile( + project, + ` + type ApiResponse = { + data?: T; + error?: E; + status: number; + }; + type UserResponse = ApiResponse; + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const ApiResponse = (T: T, E: E) => Type.Object({ + data: Type.Optional(T), + error: Type.Optional(E), + status: Type.Number() + }) + + export type ApiResponse = Static>> + + export const UserResponse = (T: T) => ApiResponse(T, Type.String()) + + export type UserResponse = Static>> + `, + true, + true, + ), + ) + }) +}) diff --git a/tests/handlers/typebox/interface-generics-consistency.test.ts b/tests/handlers/typebox/interface-generics-consistency.test.ts new file mode 100644 index 0000000..6a08912 --- /dev/null +++ b/tests/handlers/typebox/interface-generics-consistency.test.ts @@ -0,0 +1,97 @@ +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('Interface Generic Consistency with Type Aliases', () => { + let project: Project + + beforeEach(() => { + project = new Project() + }) + + test('interface and type alias should generate identical patterns for generics', () => { + // Test that both interface and type alias generate the same arrow function pattern + const interfaceSource = createSourceFile( + project, + ` + interface Container { + value: T; + id: string; + } + `, + ) + + const typeAliasSource = createSourceFile( + project, + ` + type Container = { + value: T; + id: string; + } + `, + 'type-alias.ts', + ) + + const interfaceResult = generateFormattedCode(interfaceSource, true) + const typeAliasResult = generateFormattedCode(typeAliasSource, true) + + // Both should generate the same arrow function pattern + const expectedPattern = formatWithPrettier( + ` + export const Container = (T: T) => Type.Object({ + value: T, + id: Type.String(), + }); + + export type Container = Static>>; + `, + true, + true, + ) + + expect(interfaceResult).toBe(expectedPattern) + expect(typeAliasResult).toBe(expectedPattern) + }) + + test('complex generic interface should use GenericTypeUtils flow', () => { + // This test is designed to fail if the interface parser doesn't use + // the same GenericTypeUtils.createGenericArrowFunction flow as type aliases + const sourceFile = createSourceFile( + project, + ` + interface ApiResponse { + data: T; + error: E; + status: number; + metadata: { + timestamp: string; + version: number; + }; + } + `, + ) + + const result = generateFormattedCode(sourceFile, true) + + // Should generate using the same pattern as type aliases + expect(result).toBe( + formatWithPrettier( + ` + export const ApiResponse = (T: T, E: E) => Type.Object({ + data: T, + error: E, + status: Type.Number(), + metadata: Type.Object({ + timestamp: Type.String(), + version: Type.Number(), + }), + }); + + export type ApiResponse = Static>>; + `, + true, + true, + ), + ) + }) +}) diff --git a/tests/handlers/typebox/interface-generics-runtime-binding.test.ts b/tests/handlers/typebox/interface-generics-runtime-binding.test.ts new file mode 100644 index 0000000..7a689fa --- /dev/null +++ b/tests/handlers/typebox/interface-generics-runtime-binding.test.ts @@ -0,0 +1,118 @@ +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('Interface Generic Runtime Binding', () => { + let project: Project + + beforeEach(() => { + project = new Project() + }) + + test('generic interface should generate arrow function wrapper for runtime bindings', () => { + const sourceFile = createSourceFile( + project, + ` + interface Container { + value: T; + id: string; + } + `, + ) + + const result = generateFormattedCode(sourceFile, true) + + // The generated code should be an arrow function that takes type parameters + // and returns the TypeBox expression, not just the raw TypeBox expression + expect(result).toBe( + formatWithPrettier( + ` + export const Container = (T: T) => Type.Object({ + value: T, + id: Type.String(), + }); + + export type Container = Static>>; + `, + true, + true, + ), + ) + }) + + test('generic interface with multiple type parameters should generate proper arrow function', () => { + const sourceFile = createSourceFile( + project, + ` + interface Response { + data: T; + error: E; + timestamp: number; + } + `, + ) + + const result = generateFormattedCode(sourceFile, true) + + expect(result).toBe( + formatWithPrettier( + ` + export const Response = (T: T, E: E) => Type.Object({ + data: T, + error: E, + timestamp: Type.Number(), + }); + + export type Response = Static>>; + `, + true, + true, + ), + ) + }) + + test('should fail with current implementation - demonstrates the issue', () => { + // This test is designed to fail with the current implementation + // to show that we need to fix the generic interface handling + const sourceFile = createSourceFile( + project, + ` + interface GenericContainer { + first: T; + second: U; + metadata: { + created: string; + updated: string; + }; + } + `, + ) + + const result = generateFormattedCode(sourceFile, true) + + // This should generate an arrow function, but if the current implementation + // is broken, it might generate something like: + // export const GenericContainer = Type.Object({...}) + // instead of: + // export const GenericContainer = (T: T, U: U) => Type.Object({...}) + + expect(result).toBe( + formatWithPrettier( + ` + export const GenericContainer = (T: T, U: U) => Type.Object({ + first: T, + second: U, + metadata: Type.Object({ + created: Type.String(), + updated: Type.String(), + }), + }); + + export type GenericContainer = Static>>; + `, + true, + true, + ), + ) + }) +}) diff --git a/tests/handlers/typebox/interfaces.test.ts b/tests/handlers/typebox/interfaces.test.ts index 4a1c697..e813622 100644 --- a/tests/handlers/typebox/interfaces.test.ts +++ b/tests/handlers/typebox/interfaces.test.ts @@ -200,106 +200,5 @@ describe('Interfaces', () => { `), ) }) - - describe('generic types', () => { - test('generic types', () => { - const sourceFile = createSourceFile( - project, - ` - interface A { a: T } - interface B extends A { b: number } - `, - ) - - expect(generateFormattedCode(sourceFile)).toBe( - formatWithPrettier( - ` - export const A = (T: T) => Type.Object({ - a: T - }); - - export type A = Static>>; - - export const B = Type.Composite([A(Type.Number()), Type.Object({ - b: Type.Number() - })]); - - export type B = Static; - `, - true, - true, - ), - ) - }) - - test('generic types extension', () => { - const sourceFile = createSourceFile( - project, - ` - interface A { a: T } - interface B extends A { b: T } - `, - ) - - expect(generateFormattedCode(sourceFile)).toBe( - formatWithPrettier( - ` - export const A = (T: T) => Type.Object({ - a: T - }); - - export type A = Static>>; - - export const B = (T: T) => Type.Composite([A(T), Type.Object({ - b: T - })]); - - export type B = Static>>; - `, - true, - true, - ), - ) - }) - - test('generic types with extended type', () => { - const sourceFile = createSourceFile( - project, - ` - declare const A: readonly ["a", "b"] - type A = typeof A[number] - interface B { a: T } - type C = B<'a'> - type D = B<'b'> - `, - ) - - expect(generateFormattedCode(sourceFile)).toBe( - formatWithPrettier( - ` - export const A = Type.Union([Type.Literal('a'), Type.Literal('b')]) - - export type A = Static - - export const B = (T: T) => Type.Object({ - a: T - }) - - export type B = Static>> - - export const C = B(Type.Literal('a')) - - export type C = Static - - export const D = B(Type.Literal('b')) - - export type D = Static - `, - true, - true, - ), - ) - }) - }) }) }) diff --git a/tests/handlers/typebox/readonly.test.ts b/tests/handlers/typebox/readonly.test.ts new file mode 100644 index 0000000..a31c0c9 --- /dev/null +++ b/tests/handlers/typebox/readonly.test.ts @@ -0,0 +1,153 @@ +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('Readonly types', () => { + let project: Project + + beforeEach(() => { + project = new Project() + }) + + describe('Readonly utility type', () => { + test('simple readonly object', () => { + const sourceFile = createSourceFile(project, 'type Test = Readonly<{ a: string }>') + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Readonly(Type.Object({ + a: Type.String() + })) + + export type Test = Static + `, + ), + ) + }) + + test('nested readonly types', () => { + const sourceFile = createSourceFile( + project, + 'type Test = Partial>>', + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Partial(Type.Readonly(Type.Record(Type.String(), Type.Number()))) + + export type Test = Static + `, + ), + ) + }) + + test('generic readonly type', () => { + const sourceFile = createSourceFile(project, 'type Test = Readonly') + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = (T: T) => Type.Readonly(T) + + export type Test = Static>> + `, + true, + true, + ), + ) + }) + }) + + describe('Readonly array modifier', () => { + test('readonly string array', () => { + const sourceFile = createSourceFile(project, 'type Test = readonly string[]') + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Readonly(Type.Array(Type.String())) + + export type Test = Static + `, + ), + ) + }) + + test('readonly custom type array', () => { + const sourceFile = createSourceFile( + project, + ` + type CustomType = { id: string } + type Test = readonly CustomType[] + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const CustomType = Type.Object({ + id: Type.String() + }) + + export type CustomType = Static + + export const Test = Type.Readonly(Type.Array(CustomType)) + + export type Test = Static + `, + ), + ) + }) + + test('readonly tuple', () => { + const sourceFile = createSourceFile(project, 'type Test = readonly [string, number]') + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Readonly(Type.Tuple([Type.String(), Type.Number()])) + + export type Test = Static + `, + ), + ) + }) + }) + + describe('Mixed readonly scenarios', () => { + test('readonly array inside readonly utility type', () => { + const sourceFile = createSourceFile( + project, + 'type Test = Readonly<{ items: readonly string[] }>', + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Readonly(Type.Object({ + items: Type.Readonly(Type.Array(Type.String())) + })) + + export type Test = Static + `, + ), + ) + }) + + test('readonly utility type with array', () => { + const sourceFile = createSourceFile(project, 'type Test = Readonly') + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` + export const Test = Type.Readonly(Type.Array(Type.String())) + + export type Test = Static + `, + ), + ) + }) + }) +})