-
Notifications
You must be signed in to change notification settings - Fork 0
refactor: extract string keys utility and optimize omit type handler #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,12 @@ | ||
| import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' | ||
| import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' | ||
| 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 { Node, SyntaxKind, ts, TypeOperatorTypeNode } from 'ts-morph' | ||
| import { SyntaxKind, ts } from 'ts-morph' | ||
|
|
||
| export class KeyOfTypeHandler extends BaseTypeHandler { | ||
| canHandle(node: Node): boolean { | ||
| return Node.isTypeOperatorTypeNode(node) && node.getOperator() === SyntaxKind.KeyOfKeyword | ||
| } | ||
|
|
||
| handle(node: TypeOperatorTypeNode): ts.Expression { | ||
| const operandType = node.getTypeNode() | ||
| const typeboxOperand = getTypeBoxType(operandType) | ||
| export class KeyOfTypeHandler extends TypeOperatorBaseHandler { | ||
| protected readonly operatorKind = SyntaxKind.KeyOfKeyword | ||
| protected readonly typeBoxMethod = 'KeyOf' | ||
|
|
||
| return makeTypeCall('KeyOf', [typeboxOperand]) | ||
| protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { | ||
| return makeTypeCall('KeyOf', [innerType]) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,12 @@ | ||
| import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' | ||
| import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' | ||
| 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 { Node, SyntaxKind, ts, TypeOperatorTypeNode } from 'ts-morph' | ||
| import { SyntaxKind, ts } from 'ts-morph' | ||
|
|
||
| export class ReadonlyTypeHandler extends BaseTypeHandler { | ||
| canHandle(node: Node): boolean { | ||
| return Node.isTypeOperatorTypeNode(node) && node.getOperator() === SyntaxKind.ReadonlyKeyword | ||
| } | ||
|
|
||
| handle(node: TypeOperatorTypeNode): ts.Expression { | ||
| const operandType = node.getTypeNode() | ||
| const typeboxOperand = getTypeBoxType(operandType) | ||
| export class ReadonlyTypeHandler extends TypeOperatorBaseHandler { | ||
| protected readonly operatorKind = SyntaxKind.ReadonlyKeyword | ||
| protected readonly typeBoxMethod = 'Readonly' | ||
|
|
||
| // TypeBox uses Readonly utility type for readonly modifiers | ||
| return makeTypeCall('Readonly', [typeboxOperand]) | ||
| protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { | ||
| return makeTypeCall('Readonly', [innerType]) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,8 @@ | ||||||||||||||||
| import { TypeReferenceBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/type-reference-base-handler' | ||||||||||||||||
| import { | ||||||||||||||||
| createTypeBoxKeys, | ||||||||||||||||
| extractStringKeys, | ||||||||||||||||
| } from '@daxserver/validation-schema-codegen/utils/key-extraction-utils' | ||||||||||||||||
| import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' | ||||||||||||||||
| import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' | ||||||||||||||||
| import { Node, ts } from 'ts-morph' | ||||||||||||||||
|
|
@@ -16,36 +20,8 @@ export class PickTypeHandler extends TypeReferenceBaseHandler { | |||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const typeboxObjectType = getTypeBoxType(objectType) | ||||||||||||||||
|
|
||||||||||||||||
| let pickKeys: string[] = [] | ||||||||||||||||
| if (Node.isUnionTypeNode(keysType)) { | ||||||||||||||||
| pickKeys = keysType.getTypeNodes().map((unionType) => { | ||||||||||||||||
| if (Node.isLiteralTypeNode(unionType)) { | ||||||||||||||||
| const literalExpression = unionType.getLiteral() | ||||||||||||||||
| if (Node.isStringLiteral(literalExpression)) { | ||||||||||||||||
| return literalExpression.getLiteralText() | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| return '' // Should not happen if keys are string literals | ||||||||||||||||
| }) | ||||||||||||||||
| } else if (Node.isLiteralTypeNode(keysType)) { | ||||||||||||||||
| const literalExpression = keysType.getLiteral() | ||||||||||||||||
| if (Node.isStringLiteral(literalExpression)) { | ||||||||||||||||
| pickKeys = [literalExpression.getLiteralText()] | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| let typeboxKeys: ts.Expression | ||||||||||||||||
| if (pickKeys.length === 1) { | ||||||||||||||||
| typeboxKeys = makeTypeCall('Literal', [ts.factory.createStringLiteral(pickKeys[0]!)]) | ||||||||||||||||
| } else { | ||||||||||||||||
| typeboxKeys = makeTypeCall('Union', [ | ||||||||||||||||
| ts.factory.createArrayLiteralExpression( | ||||||||||||||||
| pickKeys.map((k) => makeTypeCall('Literal', [ts.factory.createStringLiteral(k)])), | ||||||||||||||||
| false, | ||||||||||||||||
| ), | ||||||||||||||||
| ]) | ||||||||||||||||
| } | ||||||||||||||||
| const pickKeys = extractStringKeys(keysType) | ||||||||||||||||
| const typeboxKeys = createTypeBoxKeys(pickKeys) | ||||||||||||||||
|
|
||||||||||||||||
|
Comment on lines
+23
to
25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent ❓ Verification inconclusiveBug risk: empty/unsupported keys produce Type.Union([]); add a safe fallback and dedupe. When keysType is not a union of string literal types (e.g., Pick<T, keyof T>, numeric/symbol keys, aliased unions), extractStringKeys returns [], and createTypeBoxKeys([]) builds Type.Union([]). That’s likely invalid for Type.Pick and deviates from TS semantics (Pick<T, never> -> {}), causing incorrect codegen. Use getTypeBoxType(keysType) as a fallback, and dedupe keys to avoid repeated literals. Apply this diff: - const pickKeys = extractStringKeys(keysType)
- const typeboxKeys = createTypeBoxKeys(pickKeys)
+ const pickKeys = extractStringKeys(keysType)
+ const typeboxKeys =
+ pickKeys.length > 0
+ ? createTypeBoxKeys([...new Set(pickKeys)])
+ : getTypeBoxType(keysType)Follow-up:
Run quick checks to find potentially affected call sites: 🏁 Script executed: #!/usr/bin/env bash
# Pick/Omit with `keyof` (unsupported by extractStringKeys) that may regress.
rg -nP --type=ts -C2 '\bPick<[^,]+,\s*keyof\b'
rg -nP --type=ts -C2 '\bOmit<[^,]+,\s*keyof\b'
# Picks with non-string literal unions
rg -nP --type=ts -C2 '\bPick<[^,]+,\s*[^\'"]' | rg -nP -v "['\"]"Length of output: 214 🏁 Script executed: #!/usr/bin/env bash
# Quick check: Pick/Omit with `keyof` (unsupported by extractStringKeys)
rg -nP --type=ts -C2 "\\bPick<[^,]+,\\s*keyof\\b"
rg -nP --type=ts -C2 "\\bOmit<[^,]+,\\s*keyof\\b"
# Note: picks with non-string-literal unions are more complex to detect reliably via regex;
# please review manually for cases like Pick<T, MyUnionOfKeys> where MyUnionOfKeys isn’t a string literal union.Length of output: 97 Enforce safe fallback and dedupe for Pick-type key handling A search for direct - const pickKeys = extractStringKeys(keysType)
- const typeboxKeys = createTypeBoxKeys(pickKeys)
+ const pickKeys = extractStringKeys(keysType)
+ const typeboxKeys =
+ pickKeys.length > 0
+ ? createTypeBoxKeys([...new Set(pickKeys)])
+ : getTypeBoxType(keysType)• Dedupe literal keys via 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| return makeTypeCall('Pick', [typeboxObjectType, typeboxKeys]) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' | ||
| import { isTypeOperatorWithOperator } from '@daxserver/validation-schema-codegen/utils/node-type-utils' | ||
| import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' | ||
| import { Node, SyntaxKind, ts, TypeOperatorTypeNode } from 'ts-morph' | ||
|
|
||
| /** | ||
| * Base class for TypeOperator handlers (KeyOf, Readonly, etc.) | ||
| * Provides common functionality for handling TypeOperatorTypeNode | ||
| */ | ||
| export abstract class TypeOperatorBaseHandler extends BaseTypeHandler { | ||
| protected abstract readonly operatorKind: SyntaxKind | ||
| protected abstract readonly typeBoxMethod: string | ||
|
|
||
| canHandle(node: Node): boolean { | ||
| return isTypeOperatorWithOperator(node, this.operatorKind) | ||
| } | ||
|
|
||
| handle(node: TypeOperatorTypeNode): ts.Expression { | ||
| const innerType = node.getTypeNode() | ||
| const typeboxInnerType = getTypeBoxType(innerType) | ||
| return this.createTypeBoxCall(typeboxInnerType) | ||
| } | ||
|
Comment on lines
+18
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainConfirm override compatibility of handle(...) with BaseTypeHandler. This class overrides handle(node: TypeOperatorTypeNode), which is narrower than a common BaseTypeHandler signature of handle(node: Node). If BaseTypeHandler indeed declares handle(node: Node), this override will not be type-compatible in TS. If so, change the signature to Node and narrow inside. Run: If BaseTypeHandler expects Node, consider: - handle(node: TypeOperatorTypeNode): ts.Expression {
- const innerType = node.getTypeNode()
+ handle(node: Node): ts.Expression {
+ // canHandle() guarantees TypeOperatorTypeNode; assert to satisfy typing
+ const opNode = node as TypeOperatorTypeNode
+ const innerType = opNode.getTypeNode()
const typeboxInnerType = getTypeBoxType(innerType)
return this.createTypeBoxCall(typeboxInnerType)
}🏁 Script executed: #!/usr/bin/env bash
# Inspect BaseTypeHandler.handle signature and all overrides.
rg -nP --type=ts -C3 'class\s+BaseTypeHandler\b'
rg -nP --type=ts -C2 'abstract\s+handle\s*\(\s*node\s*:\s*Node'
rg -nP --type=ts -C2 'handle\s*\(\s*node\s*:\s*TypeOperatorTypeNode'Length of output: 1772 Ensure The base class declares abstract handle(node: Node): ts.Expressionbut here you’ve overridden it with a narrower parameter type ( - handle(node: TypeOperatorTypeNode): ts.Expression {
- const innerType = node.getTypeNode()
+ handle(node: Node): ts.Expression {
+ // `canHandle` already ensures this is a TypeOperatorTypeNode
+ const opNode = node as TypeOperatorTypeNode
+ const innerType = opNode.getTypeNode()
const typeboxInnerType = getTypeBoxType(innerType)
return this.createTypeBoxCall(typeboxInnerType)
}• File needing update: 🤖 Prompt for AI Agents |
||
|
|
||
| protected abstract createTypeBoxCall(innerType: ts.Expression): ts.Expression | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' | ||
| import { Node, ts } from 'ts-morph' | ||
|
|
||
| /** | ||
| * Extracts string keys from a TypeScript type node (typically used for Pick/Omit key types) | ||
| * Handles both single literal types and union types containing string literals | ||
| */ | ||
| export const extractStringKeys = (keysType: Node): string[] => { | ||
| const keys: string[] = [] | ||
|
|
||
| if (Node.isUnionTypeNode(keysType)) { | ||
| for (const unionType of keysType.getTypeNodes()) { | ||
| if (Node.isLiteralTypeNode(unionType)) { | ||
| const literalExpression = unionType.getLiteral() | ||
| if (Node.isStringLiteral(literalExpression)) { | ||
| keys.push(literalExpression.getLiteralText()) | ||
| } | ||
| } | ||
| } | ||
| } else if (Node.isLiteralTypeNode(keysType)) { | ||
| const literalExpression = keysType.getLiteral() | ||
| if (Node.isStringLiteral(literalExpression)) { | ||
| keys.push(literalExpression.getLiteralText()) | ||
| } | ||
| } | ||
|
|
||
| return keys | ||
| } | ||
|
|
||
| /** | ||
| * Converts an array of string keys into a TypeBox expression | ||
| * Returns a single Literal for one key, or a Union of Literals for multiple keys | ||
| */ | ||
| export const createTypeBoxKeys = (keys: string[]): ts.Expression => { | ||
| if (keys.length === 1) { | ||
| return makeTypeCall('Literal', [ts.factory.createStringLiteral(keys[0]!)]) | ||
| } | ||
|
|
||
| return makeTypeCall('Union', [ | ||
| ts.factory.createArrayLiteralExpression( | ||
| keys.map((k) => makeTypeCall('Literal', [ts.factory.createStringLiteral(k)])), | ||
| false, | ||
| ), | ||
| ]) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { Node, SyntaxKind, TypeReferenceNode } from 'ts-morph' | ||
|
|
||
| /** | ||
| * Utility functions for common Node type checks used in canHandle methods | ||
| */ | ||
|
|
||
| /** | ||
| * Checks if a node is a specific SyntaxKind | ||
| */ | ||
| export const isSyntaxKind = (node: Node, kind: SyntaxKind): boolean => { | ||
| return node.getKind() === kind | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a node is any of the specified SyntaxKinds | ||
| */ | ||
| export const isAnySyntaxKind = (node: Node, kinds: readonly SyntaxKind[]): boolean => { | ||
| return kinds.includes(node.getKind()) | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a node is a TypeOperatorTypeNode with a specific operator | ||
| */ | ||
| export const isTypeOperatorWithOperator = (node: Node, operator: SyntaxKind): boolean => { | ||
| return Node.isTypeOperatorTypeNode(node) && node.getOperator() === operator | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a node is a TypeReference with a specific type name | ||
| */ | ||
| export const isTypeReferenceWithName = (node: Node, typeName: string): boolean => { | ||
| if (!Node.isTypeReference(node)) { | ||
| return false | ||
| } | ||
|
|
||
| const typeRefNode = node as TypeReferenceNode | ||
| const typeNameNode = typeRefNode.getTypeName() | ||
|
|
||
| return Node.isIdentifier(typeNameNode) && typeNameNode.getText() === typeName | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a node is a TypeReference with any of the specified type names | ||
| */ | ||
| export const isTypeReferenceWithAnyName = (node: Node, typeNames: string[]): boolean => { | ||
| if (!Node.isTypeReference(node)) { | ||
| return false | ||
| } | ||
|
|
||
| const typeRefNode = node as TypeReferenceNode | ||
| const typeNameNode = typeRefNode.getTypeName() | ||
|
|
||
| return Node.isIdentifier(typeNameNode) && typeNames.includes(typeNameNode.getText()) | ||
| } | ||
|
|
||
| /** | ||
| * Utility type operators | ||
| */ | ||
| export const UTILITY_TYPE_NAMES = [ | ||
| 'Partial', | ||
| 'Required', | ||
| 'Readonly', | ||
| 'Pick', | ||
| 'Omit', | ||
| 'Exclude', | ||
| 'Extract', | ||
| 'NonNullable', | ||
| 'ReturnType', | ||
| 'InstanceType', | ||
| ] as const |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Fix empty/partial key extraction: avoid Type.Union([]) and lossy omission.
If keysType isn’t a pure union of string literals (e.g., keyof T, or a mixed union), extractStringKeys returns [] or a partial list. Passing that to createTypeBoxKeys builds an invalid union and/or drops members, changing semantics.
Apply a safe fallback to getTypeBoxType(keysType) unless the union is fully string-literal:
Please mirror the same logic in PickTypeHandler for consistency. I can submit the corresponding change if helpful.
🏁 Script executed:
Length of output: 27174
🏁 Script executed:
Length of output: 1546
🏁 Script executed:
Length of output: 1147
🏁 Script executed:
Length of output: 1147
Prevent invalid key unions in Omit & Pick handlers
Both OmitTypeHandler and PickTypeHandler always call
but extractStringKeys only returns the full set of string literals when keysType is literally a union of string literals or a single literal. For any other expression—
keyof T,T extends U ? …, mixed unions—extractStringKeysreturns[](or a subset), leading toType.Union([])or dropped members and a semantic mismatch.You should guard against this by falling back to the raw keysType via getTypeBoxType(keysType) unless:
keysTypeis a literal or a union of only string-literal type nodes, andextractStringKeysreturned at least one key.Please apply the same fix in both handlers:
• src/handlers/typebox/reference/omit-type-handler.ts (in handle):
• src/handlers/typebox/reference/pick-type-handler.ts (in handle):
This ensures that whenever we can’t reliably extract every literal key, we let TypeBox handle the original keysType expression instead of generating an empty or lossy union.
🤖 Prompt for AI Agents