diff --git a/docs/handler-system.md b/docs/handler-system.md index c6c157d..1fb5040 100644 --- a/docs/handler-system.md +++ b/docs/handler-system.md @@ -42,6 +42,7 @@ export abstract class BaseTypeHandler { - `TemplateLiteralTypeHandler` - `template ${string}` - `KeyofTypeHandler` - keyof T +- `KeyOfTypeofHandler` - keyof typeof obj - `IndexedAccessTypeHandler` - T[K] ## Handler Management diff --git a/docs/overview.md b/docs/overview.md index 2a5f067..5255e2e 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -11,6 +11,7 @@ Transforms TypeScript types to TypeBox schemas. - Union/intersection types - Utility types (Pick, Omit, Partial, Required, Record, Readonly) - Template literal types +- keyof typeof expressions - Date type ## Usage diff --git a/src/handlers/typebox/keyof-typeof-handler.ts b/src/handlers/typebox/keyof-typeof-handler.ts new file mode 100644 index 0000000..e0be21f --- /dev/null +++ b/src/handlers/typebox/keyof-typeof-handler.ts @@ -0,0 +1,90 @@ +import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' +import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' +import { Node, ts, TypeOperatorTypeNode, VariableDeclaration } from 'ts-morph' + +export class KeyOfTypeofHandler extends BaseTypeHandler { + canHandle(node: Node): boolean { + return ( + Node.isTypeOperatorTypeNode(node) && + node.getOperator() === ts.SyntaxKind.KeyOfKeyword && + Node.isTypeQuery(node.getTypeNode()) + ) + } + + handle(node: TypeOperatorTypeNode): ts.Expression { + const typeQuery = node.getTypeNode() + if (!Node.isTypeQuery(typeQuery)) return makeTypeCall('Any') + + const exprName = typeQuery.getExprName() + if (!Node.isIdentifier(exprName)) return makeTypeCall('Any') + + const keys = this.getObjectKeys(exprName) + return keys.length > 0 ? this.createUnion(keys) : makeTypeCall('Any') + } + + private getObjectKeys(node: Node): string[] { + const sourceFile = node.getSourceFile() + + for (const varDecl of sourceFile.getVariableDeclarations()) { + if (varDecl.getName() === node.getText()) { + return this.extractKeys(varDecl) + } + } + + return [] + } + + private extractKeys(varDecl: VariableDeclaration): string[] { + // Try object literal + let initializer = varDecl.getInitializer() + if (Node.isAsExpression(initializer)) { + initializer = initializer.getExpression() + } + + if (Node.isObjectLiteralExpression(initializer)) { + return initializer + .getProperties() + .map((prop) => { + if (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) { + const name = prop.getName() + return typeof name === 'string' ? name : null + } + return null + }) + .filter((name): name is string => name !== null) + } + + // Try type annotation + const typeNode = varDecl.getTypeNode() + if (Node.isTypeLiteral(typeNode)) { + return typeNode + .getMembers() + .map((member) => { + if (Node.isPropertySignature(member)) { + const name = member.getName() + return typeof name === 'string' ? name : null + } + return null + }) + .filter((name): name is string => name !== null) + } + + return [] + } + + private createUnion(keys: string[]): ts.Expression { + const literals = keys.map((key) => { + const num = Number(key) + const literal = + !isNaN(num) && key === String(num) + ? ts.factory.createNumericLiteral(num) + : ts.factory.createStringLiteral(key) + + return makeTypeCall('Literal', [literal]) + }) + + return literals.length === 1 + ? literals[0]! + : makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literals)]) + } +} diff --git a/src/handlers/typebox/typebox-type-handlers.ts b/src/handlers/typebox/typebox-type-handlers.ts index b1c359c..59095e4 100644 --- a/src/handlers/typebox/typebox-type-handlers.ts +++ b/src/handlers/typebox/typebox-type-handlers.ts @@ -7,6 +7,7 @@ import { DateTypeHandler } from '@daxserver/validation-schema-codegen/handlers/t import { FunctionTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/function-type-handler' import { IndexedAccessTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/indexed-access-type-handler' import { KeyOfTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/keyof-type-handler' +import { KeyOfTypeofHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/keyof-typeof-handler' 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' @@ -45,6 +46,7 @@ export class TypeBoxTypeHandlers { const requiredTypeHandler = new RequiredTypeHandler() const typeReferenceHandler = new TypeReferenceHandler() const keyOfTypeHandler = new KeyOfTypeHandler() + const keyOfTypeofHandler = new KeyOfTypeofHandler() const indexedAccessTypeHandler = new IndexedAccessTypeHandler() const interfaceTypeHandler = new InterfaceTypeHandler() const functionTypeHandler = new FunctionTypeHandler() @@ -89,6 +91,7 @@ export class TypeBoxTypeHandlers { // Fallback handlers for complex cases this.fallbackHandlers = [ + keyOfTypeofHandler, // Must come before keyOfTypeHandler typeReferenceHandler, keyOfTypeHandler, typeofTypeHandler, diff --git a/tests/handlers/typebox/keyof-typeof.test.ts b/tests/handlers/typebox/keyof-typeof.test.ts new file mode 100644 index 0000000..1cd833c --- /dev/null +++ b/tests/handlers/typebox/keyof-typeof.test.ts @@ -0,0 +1,123 @@ +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { beforeEach, describe, expect, test } from 'bun:test' +import { Project } from 'ts-morph' + +describe('KeyOf typeof handling', () => { + let project: Project + + beforeEach(() => { + project = new Project() + }) + + test('should handle single string', () => { + const sourceFile = createSourceFile( + project, + ` + const A = { + a: 'a', + } + type T = keyof typeof A + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const T = Type.Literal("a"); + + export type T = Static; + `), + ) + }) + + test('shoud handle single numeric', () => { + const sourceFile = createSourceFile( + project, + ` + const A = { + 0: 'a', + } + type T = keyof typeof A + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const T = Type.Literal(0); + + export type T = Static; + `), + ) + }) + + test('should handle multiple string properties', () => { + const sourceFile = createSourceFile( + project, + ` + const A = { + a: 'a', + b: 'b', + } + type T = keyof typeof A + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const T = Type.Union([ + Type.Literal("a"), + Type.Literal("b"), + ]); + + export type T = Static; + `), + ) + }) + + test('should handle multiple numeric properties', () => { + const sourceFile = createSourceFile( + project, + ` + const A = { + 0: 'a', + 1: 'b', + }; + type T = keyof typeof A + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const T = Type.Union([ + Type.Literal(0), + Type.Literal(1), + ]); + + export type T = Static; + `), + ) + }) + + test('should handle mixed properties', () => { + const sourceFile = createSourceFile( + project, + ` + const A = { + a: 'a', + 0: 'b', + }; + type T = keyof typeof A + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const T = Type.Union([ + Type.Literal("a"), + Type.Literal(0), + ]); + + export type T = Static; + `), + ) + }) +})