From ee7205bddb083ea7462cf2dbcf1b99a4328ba9a0 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Fri, 29 Aug 2025 15:54:13 +0200 Subject: [PATCH 1/3] feat(type-aliases): add support for generic type aliases The changes in this commit add support for parsing and generating code for generic type aliases in the validation schema codegen tool. The main changes are: 1. Modify the `index.ts` file to check if any interfaces or type aliases have generic type parameters. 2. Implement the `parseGenericTypeAlias` method in the `TypeAliasParser` class to handle the parsing and code generation for generic type aliases. 3. Add a new method `createGenericTypeAliasFunction` to generate the TypeBox function definition for the generic type alias. 4. Implement the `addGenericTypeAlias` method to add the generic type alias declaration to the output file. These changes allow the validation schema codegen tool to properly handle and generate code for generic type aliases, which is an important feature for users who need to define complex schema types. --- ARCHITECTURE.md | 36 +++++- src/index.ts | 41 ++++-- src/parsers/parse-interfaces.ts | 95 ++------------ src/parsers/parse-type-aliases.ts | 64 +++++++-- src/printer/typebox-printer.ts | 2 +- src/utils/generic-type-utils.ts | 151 ++++++++++++++++++++++ tests/handlers/typebox/interfaces.test.ts | 61 +++++++++ tests/utils.ts | 11 +- 8 files changed, 347 insertions(+), 114 deletions(-) create mode 100644 src/utils/generic-type-utils.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9fa1c98..8e1b90d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,7 +24,7 @@ ### 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 - **Advanced Features**: Conditional types, mapped types, keyof operators, indexed access types @@ -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` @@ -114,7 +114,7 @@ The pr #### Specialized Parsers 1. **InterfaceParser**: - Handles both regular and generic interfaces -2. **TypeAliasParser**: - Processes type alias declarations +2. **TypeAliasParser**: - Processes both regular and generic type alias declarations 3. **EnumParser**: - Handles enum declarations 4. **FunctionParser**: - Processes function declarations 5. **ImportParser**: - Handles import resolution @@ -181,6 +181,36 @@ 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 similarly through the `InterfaceParser`: + +1. **Parameter Constraint Handling**: Converts TypeScript type parameter constraints to `TSchema` constraints +2. **Function-Based Schema Generation**: Creates TypeBox schema functions that accept type parameters +3. **Type Safety Preservation**: Maintains full TypeScript type safety through proper static type aliases + +#### 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: diff --git a/src/index.ts b/src/index.ts index ea4644e..6b7db01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,20 +8,31 @@ import type { TraversedNode } from '@daxserver/validation-schema-codegen/travers import type { VisualizationOptions } from '@daxserver/validation-schema-codegen/utils/graph-visualizer' import { Node, Project, SourceFile, ts } from 'ts-morph' -const createOutputFile = (hasGenericInterfaces: boolean) => { +const createOutputFile = (hasGenericInterfaces: boolean, hasReadonly: boolean) => { const newSourceFile = new Project().createSourceFile('output.ts', '', { overwrite: true, }) // Add imports - const namedImports = [ - 'Type', + const namedImports: { name: string; isTypeOnly: boolean }[] = [ { - name: 'Static', - isTypeOnly: true, + name: 'Type', + isTypeOnly: false, }, ] + if (hasReadonly) { + namedImports.push({ + name: 'Readonly', + isTypeOnly: false, + }) + } + + namedImports.push({ + name: 'Static', + isTypeOnly: true, + }) + if (hasGenericInterfaces) { namedImports.push({ name: 'TSchema', @@ -75,13 +86,27 @@ 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), ) + // Check if any nodes use Readonly type operator + const hasReadonly = traversedNodes.some((t) => { + // Use ts-morph's built-in method to get all TypeReference descendants + return t.node + .getDescendants() + .filter(Node.isTypeReference) + .some((typeRef) => { + const typeName = typeRef.getTypeName() + return Node.isIdentifier(typeName) && typeName.getText() === 'Readonly' + }) + }) + // Create output file with proper imports - const newSourceFile = createOutputFile(hasGenericInterfaces) + const newSourceFile = createOutputFile(hasGenericInterfaces, hasReadonly) // Print sorted nodes to output const result = printSortedNodes(traversedNodes, newSourceFile) diff --git a/src/parsers/parse-interfaces.ts b/src/parsers/parse-interfaces.ts index 46a77e2..3c3d589 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, @@ -71,76 +58,14 @@ export class InterfaceParser extends BaseParser { ) // Add the function declaration - this.newSourceFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: interfaceName, - initializer: typeboxType, - }, - ], - }) + GenericTypeUtils.addTypeBoxVariableStatement(this.newSourceFile, interfaceName, 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, - ) - - 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, + // 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..78d0a4c --- /dev/null +++ b/src/utils/generic-type-utils.ts @@ -0,0 +1,151 @@ +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, undefined), + undefined, + ) + }) + } + + /** + * 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', undefined) + + return ts.factory.createTypeParameterDeclaration( + undefined, + ts.factory.createIdentifier(paramName), + constraintNode, + undefined, + ) + }) + } + + /** + * 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, + ts.factory.createNodeArray(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', 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, + ) + + 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/tests/handlers/typebox/interfaces.test.ts b/tests/handlers/typebox/interfaces.test.ts index 4a1c697..a5a7ba3 100644 --- a/tests/handlers/typebox/interfaces.test.ts +++ b/tests/handlers/typebox/interfaces.test.ts @@ -300,6 +300,67 @@ describe('Interfaces', () => { ), ) }) + + 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(Readonly(Type.Record(LanguageCode, V))) + + export type LanguageRecord = Static>> + `, + true, + 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/utils.ts b/tests/utils.ts index 61adfbe..2c21f3f 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,10 +3,11 @@ import synchronizedPrettier from '@prettier/sync' import { Project, SourceFile } from 'ts-morph' const prettierOptions = { parser: 'typescript' as const } -const typeboxImport = (withTSchema: boolean) => { +const typeboxImport = (withTSchema: boolean, withReadonly: boolean) => { + const readonly = withReadonly ? ' Readonly,' : '' const tschema = withTSchema ? ', type TSchema' : '' - return `import { Type, type Static${tschema} } from "@sinclair/typebox";\n` + return `import { Type,${readonly} type Static${tschema} } from "@sinclair/typebox";\n` } export const createSourceFile = (project: Project, code: string, filePath: string = 'test.ts') => { @@ -17,8 +18,9 @@ export const formatWithPrettier = ( input: string, addImport: boolean = true, withTSchema: boolean = false, + withReadonly: boolean = false, ): string => { - const code = addImport ? `${typeboxImport(withTSchema)}${input}` : input + const code = addImport ? `${typeboxImport(withTSchema, withReadonly)}${input}` : input return synchronizedPrettier.format(code, prettierOptions) } @@ -26,6 +28,7 @@ export const formatWithPrettier = ( export const generateFormattedCode = ( sourceFile: SourceFile, withTSchema: boolean = false, + withReadonly: boolean = false, ): string => { const code = generateCode({ sourceCode: sourceFile.getFullText(), @@ -33,5 +36,5 @@ export const generateFormattedCode = ( project: sourceFile.getProject(), }) - return formatWithPrettier(code, false, withTSchema) + return formatWithPrettier(code, false, withTSchema, withReadonly) } From 7a7063ca9dd44dc79bddf4641bb730c04379ae92 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sat, 30 Aug 2025 14:32:02 +0200 Subject: [PATCH 2/3] feat: fix readonly parsing, generic interfaces --- ARCHITECTURE.md | 63 +++++-- .../typebox/object/interface-type-handler.ts | 79 ++------ .../typebox/readonly-array-type-handler.ts | 12 ++ src/handlers/typebox/readonly-type-handler.ts | 18 +- src/handlers/typebox/typebox-type-handlers.ts | 5 +- src/index.ts | 32 +--- src/parsers/parse-interfaces.ts | 19 +- tests/handlers/typebox/generics.test.ts | 170 ++++++++++++++++++ .../interface-generics-consistency.test.ts | 97 ++++++++++ ...interface-generics-runtime-binding.test.ts | 118 ++++++++++++ tests/handlers/typebox/interfaces.test.ts | 162 ----------------- tests/handlers/typebox/readonly.test.ts | 153 ++++++++++++++++ tests/utils.ts | 11 +- 13 files changed, 657 insertions(+), 282 deletions(-) create mode 100644 src/handlers/typebox/readonly-array-type-handler.ts create mode 100644 tests/handlers/typebox/generics.test.ts create mode 100644 tests/handlers/typebox/interface-generics-consistency.test.ts create mode 100644 tests/handlers/typebox/interface-generics-runtime-binding.test.ts create mode 100644 tests/handlers/typebox/readonly.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8e1b90d..a1df5d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,7 +26,7 @@ - **Type Definitions**: Type aliases, interfaces, enums, and function declarations - **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 @@ -113,8 +113,8 @@ The pr #### Specialized Parsers -1. **InterfaceParser**: - Handles both regular and generic interfaces -2. **TypeAliasParser**: - Processes both regular and generic 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 @@ -196,11 +218,14 @@ The `TypeAliasParser` handles both regular and generic type aliases through spec #### Generic Interface Support -Generic interfaces are processed similarly through the `InterfaceParser`: +Generic interfaces are processed through the `InterfaceParser` using a consistent architectural pattern that mirrors the type alias flow: -1. **Parameter Constraint Handling**: Converts TypeScript type parameter constraints to `TSchema` constraints -2. **Function-Based Schema Generation**: Creates TypeBox schema functions that accept type parameters -3. **Type Safety Preservation**: Maintains full TypeScript type safety through proper static type aliases +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 @@ -264,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 6b7db01..c7c2d6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import type { TraversedNode } from '@daxserver/validation-schema-codegen/travers import type { VisualizationOptions } from '@daxserver/validation-schema-codegen/utils/graph-visualizer' import { Node, Project, SourceFile, ts } from 'ts-morph' -const createOutputFile = (hasGenericInterfaces: boolean, hasReadonly: boolean) => { +const createOutputFile = (hasGenericInterfaces: boolean) => { const newSourceFile = new Project().createSourceFile('output.ts', '', { overwrite: true, }) @@ -19,20 +19,12 @@ const createOutputFile = (hasGenericInterfaces: boolean, hasReadonly: boolean) = name: 'Type', isTypeOnly: false, }, + { + name: 'Static', + isTypeOnly: true, + }, ] - if (hasReadonly) { - namedImports.push({ - name: 'Readonly', - isTypeOnly: false, - }) - } - - namedImports.push({ - name: 'Static', - isTypeOnly: true, - }) - if (hasGenericInterfaces) { namedImports.push({ name: 'TSchema', @@ -93,20 +85,8 @@ export const generateCode = (options: InputOptions): string => { (Node.isTypeAliasDeclaration(t.node) && t.node.getTypeParameters().length > 0), ) - // Check if any nodes use Readonly type operator - const hasReadonly = traversedNodes.some((t) => { - // Use ts-morph's built-in method to get all TypeReference descendants - return t.node - .getDescendants() - .filter(Node.isTypeReference) - .some((typeRef) => { - const typeName = typeRef.getTypeName() - return Node.isIdentifier(typeName) && typeName.getText() === 'Readonly' - }) - }) - // Create output file with proper imports - const newSourceFile = createOutputFile(hasGenericInterfaces, hasReadonly) + const newSourceFile = createOutputFile(hasGenericInterfaces) // Print sorted nodes to output const result = printSortedNodes(traversedNodes, newSourceFile) diff --git a/src/parsers/parse-interfaces.ts b/src/parsers/parse-interfaces.ts index 3c3d589..4143629 100644 --- a/src/parsers/parse-interfaces.ts +++ b/src/parsers/parse-interfaces.ts @@ -49,16 +49,27 @@ 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, + + // Create the function expression using shared utilities (mirrors type-alias flow) + 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, interfaceName, typeboxType) + GenericTypeUtils.addTypeBoxVariableStatement( + this.newSourceFile, + interfaceName, + functionExpressionText, + ) // Add generic type alias using shared utility GenericTypeUtils.addGenericTypeAlias( 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 a5a7ba3..e813622 100644 --- a/tests/handlers/typebox/interfaces.test.ts +++ b/tests/handlers/typebox/interfaces.test.ts @@ -200,167 +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, - ), - ) - }) - - 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(Readonly(Type.Record(LanguageCode, V))) - - export type LanguageRecord = Static>> - `, - true, - 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/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 + `, + ), + ) + }) + }) +}) diff --git a/tests/utils.ts b/tests/utils.ts index 2c21f3f..61adfbe 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,11 +3,10 @@ import synchronizedPrettier from '@prettier/sync' import { Project, SourceFile } from 'ts-morph' const prettierOptions = { parser: 'typescript' as const } -const typeboxImport = (withTSchema: boolean, withReadonly: boolean) => { - const readonly = withReadonly ? ' Readonly,' : '' +const typeboxImport = (withTSchema: boolean) => { const tschema = withTSchema ? ', type TSchema' : '' - return `import { Type,${readonly} type Static${tschema} } from "@sinclair/typebox";\n` + return `import { Type, type Static${tschema} } from "@sinclair/typebox";\n` } export const createSourceFile = (project: Project, code: string, filePath: string = 'test.ts') => { @@ -18,9 +17,8 @@ export const formatWithPrettier = ( input: string, addImport: boolean = true, withTSchema: boolean = false, - withReadonly: boolean = false, ): string => { - const code = addImport ? `${typeboxImport(withTSchema, withReadonly)}${input}` : input + const code = addImport ? `${typeboxImport(withTSchema)}${input}` : input return synchronizedPrettier.format(code, prettierOptions) } @@ -28,7 +26,6 @@ export const formatWithPrettier = ( export const generateFormattedCode = ( sourceFile: SourceFile, withTSchema: boolean = false, - withReadonly: boolean = false, ): string => { const code = generateCode({ sourceCode: sourceFile.getFullText(), @@ -36,5 +33,5 @@ export const generateFormattedCode = ( project: sourceFile.getProject(), }) - return formatWithPrettier(code, false, withTSchema, withReadonly) + return formatWithPrettier(code, false, withTSchema) } From 8f395729fe12dbbc59a133a9218d842b0e983b74 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sat, 30 Aug 2025 14:47:38 +0200 Subject: [PATCH 3/3] chore: drop redundant parameters --- src/utils/generic-type-utils.ts | 13 +++++-------- src/utils/key-extraction-utils.ts | 1 - 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/utils/generic-type-utils.ts b/src/utils/generic-type-utils.ts index 78d0a4c..6a4ea9d 100644 --- a/src/utils/generic-type-utils.ts +++ b/src/utils/generic-type-utils.ts @@ -37,8 +37,7 @@ export class GenericTypeUtils { undefined, ts.factory.createIdentifier(paramName), undefined, - ts.factory.createTypeReferenceNode(paramName, undefined), - undefined, + ts.factory.createTypeReferenceNode(paramName), ) }) } @@ -51,13 +50,12 @@ export class GenericTypeUtils { ): ts.TypeParameterDeclaration[] { return typeParameters.map((typeParam) => { const paramName = typeParam.getName() - const constraintNode = ts.factory.createTypeReferenceNode('TSchema', undefined) + const constraintNode = ts.factory.createTypeReferenceNode('TSchema') return ts.factory.createTypeParameterDeclaration( undefined, ts.factory.createIdentifier(paramName), constraintNode, - undefined, ) }) } @@ -74,7 +72,7 @@ export class GenericTypeUtils { return ts.factory.createArrowFunction( undefined, - ts.factory.createNodeArray(functionTypeParams), + functionTypeParams, functionParams, undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), @@ -96,20 +94,19 @@ export class GenericTypeUtils { const typeParamDeclarations = typeParameters.map((typeParam) => { const paramName = typeParam.getName() // Use TSchema as the constraint for TypeBox compatibility - const constraintNode = ts.factory.createTypeReferenceNode('TSchema', undefined) + const constraintNode = ts.factory.createTypeReferenceNode('TSchema') 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), + ts.factory.createTypeReferenceNode(paramName), ) // Create typeof A expression - we need to create a type reference with type arguments 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, ), ]) }