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
85 changes: 85 additions & 0 deletions src/handlers/typebox/indexed-access-type-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,94 @@ export class IndexedAccessTypeHandler extends BaseTypeHandler {
const objectType = typeNode.getObjectTypeNode()
const indexType = typeNode.getIndexTypeNode()

// Handle special case: typeof A[number] where A is a readonly tuple
if (
objectType?.isKind(ts.SyntaxKind.TypeQuery) &&
indexType?.isKind(ts.SyntaxKind.NumberKeyword)
) {
return this.handleTypeofArrayAccess(objectType, typeNode)
}

const typeboxObjectType = this.getTypeBoxType(objectType)
const typeboxIndexType = this.getTypeBoxType(indexType)

return makeTypeCall('Index', [typeboxObjectType, typeboxIndexType])
}

private handleTypeofArrayAccess(
typeQuery: Node,
indexedAccessType: IndexedAccessTypeNode,
): ts.Expression {
const typeQueryNode = typeQuery.asKindOrThrow(ts.SyntaxKind.TypeQuery)
const exprName = typeQueryNode.getExprName()

// Get the referenced type name (e.g., "A" from "typeof A")
if (Node.isIdentifier(exprName)) {
const typeName = exprName.getText()
const sourceFile = indexedAccessType.getSourceFile()

// First try to find a type alias declaration
const typeAlias = sourceFile.getTypeAlias(typeName)
if (typeAlias) {
const tupleUnion = this.extractTupleUnion(typeAlias.getTypeNode())
if (tupleUnion) {
return tupleUnion
}
}

// Then try to find a variable declaration
const variableDeclaration = sourceFile.getVariableDeclaration(typeName)
if (variableDeclaration) {
const tupleUnion = this.extractTupleUnion(variableDeclaration.getTypeNode())
if (tupleUnion) {
return tupleUnion
}
}
}

// Fallback to default Index behavior
const typeboxObjectType = this.getTypeBoxType(typeQuery)
const typeboxIndexType = this.getTypeBoxType(indexedAccessType.getIndexTypeNode())
return makeTypeCall('Index', [typeboxObjectType, typeboxIndexType])
}

private extractTupleUnion(typeNode: Node | undefined): ts.Expression | null {
if (!typeNode) return null

let actualTupleType: Node | undefined = typeNode

// Handle readonly modifier (TypeOperator)
if (typeNode.isKind(ts.SyntaxKind.TypeOperator)) {
const typeOperator = typeNode.asKindOrThrow(ts.SyntaxKind.TypeOperator)
actualTupleType = typeOperator.getTypeNode()
}

// Check if it's a tuple type
if (actualTupleType?.isKind(ts.SyntaxKind.TupleType)) {
const tupleType = actualTupleType.asKindOrThrow(ts.SyntaxKind.TupleType)
const elements = tupleType.getElements()

// Extract literal types from tuple elements
const literalTypes: ts.Expression[] = []
for (const element of elements) {
if (element.isKind(ts.SyntaxKind.LiteralType)) {
const literalTypeNode = element.asKindOrThrow(ts.SyntaxKind.LiteralType)
const literal = literalTypeNode.getLiteral()

if (literal.isKind(ts.SyntaxKind.StringLiteral)) {
const stringLiteral = literal.asKindOrThrow(ts.SyntaxKind.StringLiteral)
const value = stringLiteral.getLiteralValue()
literalTypes.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(value)]))
}
}
}

// Return union of literal types if we found any
if (literalTypes.length > 0) {
return makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literalTypes)])
}
}

return null
}
Comment on lines +66 to +104
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 results for mixed-element tuples; return union only when all elements are string literals.

If a tuple mixes string literals with other types (e.g., [1, "a"]), the current logic returns a union of only the string literals and silently drops the rest, producing an unsound schema. You should either:

  • Bail out to the default Index behavior unless every element is a string literal, or
  • Fully cover other literal kinds (number/boolean) and named/optional/rest tuple members.

At minimum, gate on “all elements are string literals.”

Apply this diff to bail unless all elements are string literals and to support named tuple members:

   private extractTupleUnion(typeNode: Node | undefined): ts.Expression | null {
     if (!typeNode) return null
 
     let actualTupleType: Node | undefined = typeNode
 
     // Handle readonly modifier (TypeOperator)
     if (typeNode.isKind(ts.SyntaxKind.TypeOperator)) {
       const typeOperator = typeNode.asKindOrThrow(ts.SyntaxKind.TypeOperator)
       actualTupleType = typeOperator.getTypeNode()
     }
 
     // Check if it's a tuple type
     if (actualTupleType?.isKind(ts.SyntaxKind.TupleType)) {
       const tupleType = actualTupleType.asKindOrThrow(ts.SyntaxKind.TupleType)
       const elements = tupleType.getElements()
 
-      // Extract literal types from tuple elements
-      const literalTypes: ts.Expression[] = []
-      for (const element of elements) {
-        if (element.isKind(ts.SyntaxKind.LiteralType)) {
-          const literalTypeNode = element.asKindOrThrow(ts.SyntaxKind.LiteralType)
-          const literal = literalTypeNode.getLiteral()
-
-          if (literal.isKind(ts.SyntaxKind.StringLiteral)) {
-            const stringLiteral = literal.asKindOrThrow(ts.SyntaxKind.StringLiteral)
-            const value = stringLiteral.getLiteralValue()
-            literalTypes.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(value)]))
-          }
-        }
-      }
-
-      // Return union of literal types if we found any
-      if (literalTypes.length > 0) {
-        return makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literalTypes)])
-      }
+      // Extract literal string types from tuple elements.
+      // Bail out if any element is not a string literal.
+      const literalTypes: ts.Expression[] = []
+      for (const element of elements) {
+        // Support named tuple members: [x: "a"]
+        const candidate =
+          element.isKind(ts.SyntaxKind.NamedTupleMember) ? element.getTypeNode() : element
+        if (!candidate || !candidate.isKind(ts.SyntaxKind.LiteralType)) {
+          return null
+        }
+        const literal = candidate.asKindOrThrow(ts.SyntaxKind.LiteralType).getLiteral()
+        if (!literal.isKind(ts.SyntaxKind.StringLiteral)) {
+          return null
+        }
+        const value = literal.asKindOrThrow(ts.SyntaxKind.StringLiteral).getLiteralValue()
+        literalTypes.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(value)]))
+      }
+ 
+      // Only return a union if every element contributed a string literal
+      if (literalTypes.length === elements.length && literalTypes.length > 0) {
+        return makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literalTypes)])
+      }
     }
 
     return null
   }
📝 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 extractTupleUnion(typeNode: Node | undefined): ts.Expression | null {
if (!typeNode) return null
let actualTupleType: Node | undefined = typeNode
// Handle readonly modifier (TypeOperator)
if (typeNode.isKind(ts.SyntaxKind.TypeOperator)) {
const typeOperator = typeNode.asKindOrThrow(ts.SyntaxKind.TypeOperator)
actualTupleType = typeOperator.getTypeNode()
}
// Check if it's a tuple type
if (actualTupleType?.isKind(ts.SyntaxKind.TupleType)) {
const tupleType = actualTupleType.asKindOrThrow(ts.SyntaxKind.TupleType)
const elements = tupleType.getElements()
// Extract literal types from tuple elements
const literalTypes: ts.Expression[] = []
for (const element of elements) {
if (element.isKind(ts.SyntaxKind.LiteralType)) {
const literalTypeNode = element.asKindOrThrow(ts.SyntaxKind.LiteralType)
const literal = literalTypeNode.getLiteral()
if (literal.isKind(ts.SyntaxKind.StringLiteral)) {
const stringLiteral = literal.asKindOrThrow(ts.SyntaxKind.StringLiteral)
const value = stringLiteral.getLiteralValue()
literalTypes.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(value)]))
}
}
}
// Return union of literal types if we found any
if (literalTypes.length > 0) {
return makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literalTypes)])
}
}
return null
}
private extractTupleUnion(typeNode: Node | undefined): ts.Expression | null {
if (!typeNode) return null
let actualTupleType: Node | undefined = typeNode
// Handle readonly modifier (TypeOperator)
if (typeNode.isKind(ts.SyntaxKind.TypeOperator)) {
const typeOperator = typeNode.asKindOrThrow(ts.SyntaxKind.TypeOperator)
actualTupleType = typeOperator.getTypeNode()
}
// Check if it's a tuple type
if (actualTupleType?.isKind(ts.SyntaxKind.TupleType)) {
const tupleType = actualTupleType.asKindOrThrow(ts.SyntaxKind.TupleType)
const elements = tupleType.getElements()
// Extract literal string types from tuple elements.
// Bail out if any element is not a string literal.
const literalTypes: ts.Expression[] = []
for (const element of elements) {
// Support named tuple members: [x: "a"]
const candidate =
element.isKind(ts.SyntaxKind.NamedTupleMember) ? element.getTypeNode() : element
if (!candidate || !candidate.isKind(ts.SyntaxKind.LiteralType)) {
return null
}
const literal = candidate.asKindOrThrow(ts.SyntaxKind.LiteralType).getLiteral()
if (!literal.isKind(ts.SyntaxKind.StringLiteral)) {
return null
}
const value = literal.asKindOrThrow(ts.SyntaxKind.StringLiteral).getLiteralValue()
literalTypes.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(value)]))
}
// Only return a union if every element contributed a string literal
if (literalTypes.length === elements.length && literalTypes.length > 0) {
return makeTypeCall('Union', [ts.factory.createArrayLiteralExpression(literalTypes)])
}
}
return null
}
🤖 Prompt for AI Agents
In src/handlers/typebox/indexed-access-type-handler.ts around lines 66–104, the
current extractTupleUnion silently drops non-string-literal elements in mixed
tuples; update it to bail out (return null) unless every tuple element is a
string literal and handle named tuple members by unwrapping their inner type
node before checking. Concretely, iterate elements and for each: if it's a
NamedTupleMember extract its type node, if it's an optional/rest ensure you
examine the underlying type node, then require the node to be a LiteralType
whose literal is a StringLiteral; if any element fails that check return null;
only when all elements are string literals build the Literal calls and return
the Union as before. Ensure no partial unions are produced.

}
191 changes: 115 additions & 76 deletions tests/handlers/typebox/array-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ describe('Array types', () => {

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const A = Type.Array(Type.String());
const A = Type.Array(Type.String());

type A = Static<typeof A>;
`),
type A = Static<typeof A>;
`),
)
})

Expand All @@ -27,61 +27,83 @@ describe('Array types', () => {

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const A = Type.Array(Type.String());
const A = Type.Array(Type.String());

type A = Static<typeof A>;
`),
type A = Static<typeof A>;
`),
)
})

test('array spread', () => {
const sourceFile = createSourceFile(
project,
`
declare const A: readonly ["a", "b", "c"];
type A = typeof A[number];
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const A = Type.Union([Type.Literal("a"), Type.Literal("b"), Type.Literal("c")]);

type A = Static<typeof A>;
`),
)
})

test('Union', () => {
const sourceFile = createSourceFile(
project,
`type A = number;
type B = string;
type T = A | B;`,
`
type A = number;
type B = string;
type T = A | B;
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const A = Type.Number();
const A = Type.Number();

type A = Static<typeof A>;
type A = Static<typeof A>;

const B = Type.String();
const B = Type.String();

type B = Static<typeof B>;
type B = Static<typeof B>;

const T = Type.Union([A, B]);
const T = Type.Union([A, B]);

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

test('Intersect', () => {
const sourceFile = createSourceFile(
project,
`type T = {
x: number;
} & {
y: string;
};`,
`
type T = {
x: number;
} & {
y: string;
};
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const T = Type.Intersect([
Type.Object({
x: Type.Number(),
}),
Type.Object({
y: Type.String(),
}),
]);

type T = Static<typeof T>;
`),
const T = Type.Intersect([
Type.Object({
x: Type.Number(),
}),
Type.Object({
y: Type.String(),
}),
]);

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

Expand All @@ -90,10 +112,10 @@ describe('Array types', () => {

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
const T = Type.Union([Type.Literal("a"), Type.Literal("b")]);
const T = Type.Union([Type.Literal("a"), Type.Literal("b")]);

type T = Static<typeof T>;
`),
type T = Static<typeof T>;
`),
)
})
})
Expand All @@ -104,10 +126,10 @@ describe('Array types', () => {

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const A = Type.Array(Type.String());
export const A = Type.Array(Type.String());

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

Expand All @@ -116,78 +138,95 @@ describe('Array types', () => {

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const A = Type.Array(Type.String());
export const A = Type.Array(Type.String());

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

test('array spread', () => {
const sourceFile = createSourceFile(
project,
`
export declare const A: readonly ["a", "b", "c"];
export type A = typeof A[number];
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const A = Type.Union([Type.Literal("a"), Type.Literal("b"), Type.Literal("c")]);

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

test('Union', () => {
const sourceFile = createSourceFile(
project,
`export type A = number;
export type B = string;
export type T = A | B;`,
`
export type A = number;
export type B = string;
export type T = A | B;
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const A = Type.Number();
export const A = Type.Number();

export type A = Static<typeof A>;
export type A = Static<typeof A>;

export const B = Type.String();
export const B = Type.String();

export type B = Static<typeof B>;
export type B = Static<typeof B>;

export const T = Type.Union([A, B]);
export const T = Type.Union([A, B]);

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

test('Intersect', () => {
const sourceFile = createSourceFile(
project,
`export type T = {
x: number;
} & {
y: string;
};`,
`
export type T = {
x: number;
} & {
y: string;
};
`,
)

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const T = Type.Intersect([
Type.Object({
x: Type.Number(),
}),
Type.Object({
y: Type.String(),
}),
]);

export type T = Static<typeof T>;
`),
export const T = Type.Intersect([
Type.Object({
x: Type.Number(),
}),
Type.Object({
y: Type.String(),
}),
]);

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

test('Literal', () => {
const sourceFile = createSourceFile(
project,
`
export type T = "a" | "b";
`,
)
const sourceFile = createSourceFile(project, 'export type T = "a" | "b";')

expect(generateFormattedCode(sourceFile)).resolves.toBe(
formatWithPrettier(`
export const T = Type.Union([Type.Literal("a"), Type.Literal("b")]);
export const T = Type.Union([Type.Literal("a"), Type.Literal("b")]);

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