Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/handler-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export abstract class BaseTypeHandler {

- `TemplateLiteralTypeHandler` - `template ${string}`
- `KeyofTypeHandler` - keyof T
- `KeyOfTypeofHandler` - keyof typeof obj
- `IndexedAccessTypeHandler` - T[K]

## Handler Management
Expand Down
1 change: 1 addition & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions src/handlers/typebox/keyof-typeof-handler.ts
Original file line number Diff line number Diff line change
@@ -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')
}
Comment on lines +14 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Empty objects should yield Never, not Any.

keyof {} is never. Your current [] -> Any conflates “resolved empty” with “unresolved”. Return Any only when the target couldn’t be resolved; return Never for resolved-but-empty.

-  handle(node: TypeOperatorTypeNode): ts.Expression {
+  handle(node: TypeOperatorTypeNode): ts.Expression {
     const typeQuery = node.getTypeNode()
-    if (!Node.isTypeQuery(typeQuery)) return makeTypeCall('Any')
+    if (!Node.isTypeQuery(typeQuery)) return makeTypeCall('Any')
 
     const exprName = typeQuery.getExprName()
-    if (!Node.isIdentifier(exprName)) return makeTypeCall('Any')
+    if (!Node.isIdentifier(exprName)) return makeTypeCall('Any')
 
-    const keys = this.getObjectKeys(exprName)
-    return keys.length > 0 ? this.createUnion(keys) : makeTypeCall('Any')
+    const keys = this.getObjectKeys(exprName)
+    if (keys === null) return makeTypeCall('Any')
+    if (keys.length === 0) return makeTypeCall('Never')
+    return this.createUnion(keys)
   }
 
-  private getObjectKeys(node: Node): string[] {
+  private getObjectKeys(node: Node): string[] | null {
     const sourceFile = node.getSourceFile()
 
     for (const varDecl of sourceFile.getVariableDeclarations()) {
       if (varDecl.getName() === node.getText()) {
         return this.extractKeys(varDecl)
       }
     }
 
-    return []
+    return null
   }

Also applies to: 25-35

🤖 Prompt for AI Agents
In src/handlers/typebox/keyof-typeof-handler.ts around lines 14-23 (and
similarly for 25-35), the handler currently treats an empty keys array as
unresolved and returns Any; instead, return Never for a resolved-but-empty
object and only return Any when resolution truly failed. Modify getObjectKeys
(or its contract) so it returns a distinct sentinel (e.g., null) when the target
could not be resolved; then update this handler to: if keys === null return
makeTypeCall('Any'); else if keys.length === 0 return makeTypeCall('Never');
otherwise return createUnion(keys). Apply the same fix to the second block at
lines 25-35.


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)])
}
Comment on lines +75 to +89
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Incorrect: numeric object keys should produce string literal key types.

In TypeScript, property names are strings; keyof typeof { 0: 'a' } is "0", not 0. Emitting Type.Literal(0) is semantically wrong.

Apply this diff to always emit string literals for object keys:

-  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])
-    })
+  private createUnion(keys: string[]): ts.Expression {
+    const literals = keys.map((key) => {
+      const literal = ts.factory.createStringLiteral(key)
+      return makeTypeCall('Literal', [literal])
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)])
}
private createUnion(keys: string[]): ts.Expression {
const literals = keys.map((key) => {
const literal = ts.factory.createStringLiteral(key)
return makeTypeCall('Literal', [literal])
})
return literals.length === 1
? literals[0]!
: makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literals)])
}
🤖 Prompt for AI Agents
In src/handlers/typebox/keyof-typeof-handler.ts around lines 75 to 89, the code
currently converts numeric-looking object keys into numeric literals which is
incorrect because object property keys are always strings; update the
createUnion function to always create string literals for keys (i.e. use
ts.factory.createStringLiteral(key) for every key) instead of conditionally
creating NumericLiteral for numeric-looking keys, and keep the rest of the logic
that wraps literals in Type.Literal and combines them with Type.Union unchanged.

}
3 changes: 3 additions & 0 deletions src/handlers/typebox/typebox-type-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -89,6 +91,7 @@ export class TypeBoxTypeHandlers {

// Fallback handlers for complex cases
this.fallbackHandlers = [
keyOfTypeofHandler, // Must come before keyOfTypeHandler
typeReferenceHandler,
keyOfTypeHandler,
typeofTypeHandler,
Expand Down
123 changes: 123 additions & 0 deletions tests/handlers/typebox/keyof-typeof.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof T>;
`),
)
})

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<typeof T>;
`),
)
Comment on lines +43 to +49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Adjust expectation: numeric object keys are string key types.

keyof typeof { 0: 'a' } should yield "0".

-        export const T = Type.Literal(0);
+        export const T = Type.Literal("0");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Literal(0);
export type T = Static<typeof T>;
`),
)
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Literal("0");
export type T = Static<typeof T>;
`),
)
🤖 Prompt for AI Agents
In tests/handlers/typebox/keyof-typeof.test.ts around lines 43 to 49, the test
expectation uses a numeric literal key (Type.Literal(0)) but JavaScript object
keys that are numeric become string keys, so the expected generated type should
be the string literal "0"; update the expected formatted code to use
Type.Literal("0") (and corresponding Static<typeof T> remains) so the assertion
matches keyof typeof { 0: 'a' } producing the string key type.

})

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<typeof T>;
`),
)
})

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<typeof T>;
`),
Comment on lines +88 to +96
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Adjust expectation for multiple numeric keys: use strings.

-        export const T = Type.Union([
-          Type.Literal(0),
-          Type.Literal(1),
-        ]);
+        export const T = Type.Union([
+          Type.Literal("0"),
+          Type.Literal("1"),
+        ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Union([
Type.Literal(0),
Type.Literal(1),
]);
export type T = Static<typeof T>;
`),
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Union([
Type.Literal("0"),
Type.Literal("1"),
]);
export type T = Static<typeof T>;
`),
🤖 Prompt for AI Agents
In tests/handlers/typebox/keyof-typeof.test.ts around lines 88–96, the expected
snapshot currently uses numeric literal types for multiple numeric keys; update
the expectation to use string literal keys instead. Replace the numeric
Type.Literal(0) and Type.Literal(1) (and any corresponding numeric type usages)
with Type.Literal("0") and Type.Literal("1") so the test matches the codegen
behavior that emits numeric keys as strings when there are multiple numeric
keys.

)
})

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),
]);

Comment on lines +112 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Adjust expectation for mixed keys: numeric key should be string literal.

-        export const T = Type.Union([
-          Type.Literal("a"),
-          Type.Literal(0),
-        ]);
+        export const T = Type.Union([
+          Type.Literal("a"),
+          Type.Literal("0"),
+        ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Union([
Type.Literal("a"),
Type.Literal(0),
]);
expect(generateFormattedCode(sourceFile)).toBe(
formatWithPrettier(`
export const T = Type.Union([
Type.Literal("a"),
Type.Literal("0"),
]);
🤖 Prompt for AI Agents
In tests/handlers/typebox/keyof-typeof.test.ts around lines 112 to 118, the test
expectation treats a mixed numeric key as a numeric literal but the generator
returns it as a string literal; update the expected formatted output so the
numeric key is represented as a string literal (e.g. Type.Literal("0")) instead
of a numeric literal, adjusting the snapshot/expect string accordingly and
re-run tests.

export type T = Static<typeof T>;
`),
)
})
})