ChainGraph CodeGen - Implementation Plan v2
Status: ✅ COMPLETE - All goals achieved!
Goals
- ✅ Fix enum title formatting (
HARM_CATEGORY→Harm Category) - DONE - ✅ Extract nested types as separate schemas - DONE
- ✅ Generate plain TypeScript enums (no decorators) - DONE (for native enums)
- ✅ Handle enum fields properly - DONE (literal unions use @PortEnum)
- ✅ Proper
@PortArraywith schema references - DONE - ✅ Full type preservation (
SafetySetting[]notRecord<string, any>[]) - DONE
Note: Gemini SDK uses string literal unions, not native TypeScript enums, so @PortEnum with inline options is correct.
Desired Output (Complete Example)
Input: Gemini SDK Types
typescript
// From @google/genai
export interface GenerateContentConfig {
httpOptions?: HttpOptions
safetySettings?: SafetySetting[]
temperature?: number
}
export interface HttpOptions {
baseUrl?: string
apiVersion?: string
}
export interface SafetySetting {
method?: HarmBlockMethod
category?: HarmCategory
threshold?: HarmBlockThreshold
}
export enum HarmBlockMethod {
HARM_BLOCK_METHOD_UNSPECIFIED = "HARM_BLOCK_METHOD_UNSPECIFIED",
SEVERITY = "SEVERITY",
PROBABILITY = "PROBABILITY"
}
export enum HarmCategory {
HARM_CATEGORY_UNSPECIFIED = "HARM_CATEGORY_UNSPECIFIED",
HARM_CATEGORY_HATE_SPEECH = "HARM_CATEGORY_HATE_SPEECH",
// ... more
}Generated Output (Perfect!)
typescript
import { ObjectSchema, PortObject, PortArray, PortNumber, PortString, PortEnumFromNative } from '@badaitech/chaingraph-types'
// ==========================================
// ENUMS (No decorators!)
// ==========================================
/** Harm block method */
export enum HarmBlockMethod {
HARM_BLOCK_METHOD_UNSPECIFIED = "HARM_BLOCK_METHOD_UNSPECIFIED",
SEVERITY = "SEVERITY",
PROBABILITY = "PROBABILITY"
}
/** Harm category */
export enum HarmCategory {
HARM_CATEGORY_UNSPECIFIED = "HARM_CATEGORY_UNSPECIFIED",
HARM_CATEGORY_HATE_SPEECH = "HARM_CATEGORY_HATE_SPEECH",
HARM_CATEGORY_DANGEROUS_CONTENT = "HARM_CATEGORY_DANGEROUS_CONTENT",
// ... all enum values
}
/** Harm block threshold */
export enum HarmBlockThreshold {
HARM_BLOCK_THRESHOLD_UNSPECIFIED = "HARM_BLOCK_THRESHOLD_UNSPECIFIED",
BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE",
BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE",
BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH",
BLOCK_NONE = "BLOCK_NONE",
OFF = "OFF"
}
// ==========================================
// OBJECT SCHEMAS (Dependencies first)
// ==========================================
/** HTTP options configuration */
@ObjectSchema({
description: 'HTTP options to be used in requests',
type: 'HttpOptions',
})
export class HttpOptions {
/** The base URL for the AI platform service endpoint */
@PortString({
title: 'Base URL',
description: 'The base URL for the AI platform service endpoint',
})
baseUrl?: string
/** Specifies the version of the API to use */
@PortString({
title: 'API Version',
description: 'Specifies the version of the API to use',
})
apiVersion?: string
}
/** Safety setting for content filtering */
@ObjectSchema({
description: 'Safety settings for blocking harmful content',
type: 'SafetySetting',
})
export class SafetySetting {
/** Harm block method */
@PortEnumFromNative(HarmBlockMethod, {
title: 'Method',
description: 'Determines if the harm block method uses probability or severity',
defaultValue: HarmBlockMethod.HARM_BLOCK_METHOD_UNSPECIFIED,
})
method?: HarmBlockMethod // ✅ Typed!
/** Harm category */
@PortEnumFromNative(HarmCategory, {
title: 'Category',
description: 'Harm category',
defaultValue: HarmCategory.HARM_CATEGORY_UNSPECIFIED,
})
category?: HarmCategory // ✅ Typed!
/** Harm block threshold */
@PortEnumFromNative(HarmBlockThreshold, {
title: 'Threshold',
description: 'The harm block threshold',
defaultValue: HarmBlockThreshold.HARM_BLOCK_THRESHOLD_UNSPECIFIED,
})
threshold?: HarmBlockThreshold // ✅ Typed!
}
// ==========================================
// MAIN SCHEMA
// ==========================================
/**
* Optional model configuration parameters.
* For more information, see Content generation parameters
*/
@ObjectSchema({
description: 'Optional model configuration parameters',
type: 'GenerateContentConfig',
})
export class GenerateContentConfig {
/** HTTP request options */
@PortObject({
title: 'HTTP Options',
description: 'Used to override HTTP request options',
schema: HttpOptions,
})
httpOptions?: HttpOptions // ✅ Fully typed!
/** Safety settings for content filtering */
@PortArray({
title: 'Safety Settings',
description: 'Safety settings in the request to block unsafe content',
defaultValue: [],
itemConfig: {
type: 'object',
schema: SafetySetting, // ✅ Reference to class!
},
isMutable: true,
})
safetySettings?: SafetySetting[] // ✅ Fully typed array!
/**
* Value that controls the degree of randomness in token selection.
* Lower temperatures are good for less open-ended responses.
*/
@PortNumber({
title: 'Temperature',
description: 'Value that controls the degree of randomness in token selection',
})
temperature?: number
}Implementation Tasks
Task 1: Fix Enum Title Formatting ⚡
File: TypeMapper.ts
typescript
private formatTitle(propertyName: string): string {
// Handle SCREAMING_SNAKE_CASE
if (propertyName === propertyName.toUpperCase() && propertyName.includes('_')) {
return propertyName
.split('_')
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ')
}
// Handle camelCase/PascalCase
const words = propertyName
.replace(/([A-Z])/g, ' $1')
.trim()
.split(/[\s_-]+/)
.filter(w => w.length > 0)
return words
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}Test:
typescript
it('should format SCREAMING_SNAKE_CASE', () => {
expect(formatTitle('HARM_CATEGORY_HATE_SPEECH')).toBe('Harm Category Hate Speech')
expect(formatTitle('BLOCK_LOW_AND_ABOVE')).toBe('Block Low And Above')
})Task 2: Create TypeDependencyCollector
New File: core/TypeDependencyCollector.ts
typescript
import { Project, InterfaceDeclaration, EnumDeclaration, TypeAliasDeclaration, Type } from 'ts-morph'
export interface TypeMetadata {
name: string
kind: 'interface' | 'enum' | 'type-alias'
declaration: InterfaceDeclaration | EnumDeclaration | TypeAliasDeclaration
dependencies: string[]
}
export class TypeDependencyCollector {
private discovered = new Map<string, TypeMetadata>()
private queue: string[] = []
private visited = new Set<string>()
constructor(private project: Project) {}
/**
* Collect all type dependencies recursively
*/
collect(rootTypeName: string, maxDepth: number = 5): Map<string, TypeMetadata> {
this.queue.push({ name: rootTypeName, depth: 0 })
while (this.queue.length > 0) {
const { name, depth } = this.queue.shift()!
if (this.visited.has(name) || depth > maxDepth) continue
this.visited.add(name)
const metadata = this.analyzeType(name)
if (!metadata) continue
this.discovered.set(name, metadata)
// Add dependencies to queue
for (const dep of metadata.dependencies) {
this.queue.push({ name: dep, depth: depth + 1 })
}
}
return this.discovered
}
private analyzeType(typeName: string): TypeMetadata | null {
// Find the type in project
for (const sourceFile of this.project.getSourceFiles()) {
// Try interface
const interfaceDecl = sourceFile.getInterface(typeName)
if (interfaceDecl) {
return {
name: typeName,
kind: 'interface',
declaration: interfaceDecl,
dependencies: this.extractInterfaceDependencies(interfaceDecl),
}
}
// Try enum
const enumDecl = sourceFile.getEnum(typeName)
if (enumDecl) {
return {
name: typeName,
kind: 'enum',
declaration: enumDecl,
dependencies: [], // Enums have no dependencies
}
}
// Try type alias
const typeAlias = sourceFile.getTypeAlias(typeName)
if (typeAlias) {
return {
name: typeName,
kind: 'type-alias',
declaration: typeAlias,
dependencies: this.extractTypeAliasDependencies(typeAlias),
}
}
}
return null
}
private extractInterfaceDependencies(decl: InterfaceDeclaration): string[] {
const deps: string[] = []
for (const prop of decl.getProperties()) {
const propType = prop.getType()
deps.push(...this.extractTypeNamesFromType(propType))
}
return [...new Set(deps)] // Remove duplicates
}
private extractTypeNamesFromType(type: Type): string[] {
const names: string[] = []
// Get direct type name
const typeName = this.getTypeName(type)
if (typeName && this.isUserDefinedType(typeName)) {
names.push(typeName)
}
// Handle arrays
if (type.isArray()) {
const elementType = type.getArrayElementType()
if (elementType) {
names.push(...this.extractTypeNamesFromType(elementType))
}
}
// Handle unions
if (type.isUnion()) {
const unionTypes = type.getUnionTypes()
for (const ut of unionTypes) {
if (!ut.isUndefined() && !ut.isNull()) {
names.push(...this.extractTypeNamesFromType(ut))
}
}
}
return names
}
private getTypeName(type: Type): string | null {
const symbol = type.getSymbol()
if (!symbol) return null
return symbol.getName()
}
private isUserDefinedType(typeName: string): boolean {
const builtIns = [
'string', 'number', 'boolean', 'any', 'unknown', 'void', 'never',
'Array', 'Record', 'Partial', 'Required', 'Pick', 'Omit',
'AbortSignal', 'Promise', 'Date', 'RegExp',
]
return !builtIns.includes(typeName)
}
}Task 3: Update CodeGenerator for Multi-Schema
File: CodeGenerator.ts
typescript
async generate(): Promise<string> {
// 1. Find and add all relevant .d.ts files
const dtsPath = this.resolveDtsPath(this.options.library)
this.addDtsFilesToProject(dtsPath)
// 2. Collect type dependencies
const collector = new TypeDependencyCollector(this.project)
const allTypes = collector.collect(
this.options.typeName,
this.options.maxDepth || 5
)
// 3. Generate each type
const generatedSchemas: GeneratedSchema[] = []
for (const [typeName, metadata] of allTypes) {
const code = this.generateSingleType(metadata, allTypes)
generatedSchemas.push({
typeName,
kind: metadata.kind,
code,
dependencies: metadata.dependencies,
})
}
// 4. Sort schemas (dependencies first)
const sorted = this.topologicalSort(generatedSchemas)
// 5. Combine into single output
return this.combineSchemas(sorted)
}
private generateSingleType(
metadata: TypeMetadata,
allTypes: Map<string, TypeMetadata>
): string {
switch (metadata.kind) {
case 'enum':
return this.templateEngine.generateEnum(metadata)
case 'interface':
return this.templateEngine.generateObjectSchema(metadata, allTypes)
default:
// Skip type aliases for now
return ''
}
}Task 4: Update TypeMapper for Type Name Extraction
File: TypeMapper.ts
typescript
class TypeMapper {
// Track discovered types for dependency collection
private discoveredTypes = new Set<string>()
mapType(type: Type, propertyName: string, context?: {
collectDependencies?: boolean
}): IPortConfig {
// ... existing logic
// When mapping objects, extract the type name
if (type.isObject() && !type.isArray()) {
const typeName = this.extractTypeName(type)
if (typeName && context?.collectDependencies) {
this.discoveredTypes.add(typeName)
// Return a reference config
return {
type: 'object',
title: this.formatTitle(propertyName),
schema: typeName, // ✅ Type name, not inline schema
}
}
// Fallback to inline schema
return this.mapObjectInline(type, propertyName)
}
}
private extractTypeName(type: Type): string | null {
const symbol = type.getSymbol()
if (!symbol) return null
const declarations = symbol.getDeclarations()
if (declarations.length === 0) return null
const decl = declarations[0]
// Check if it's a named interface or type alias
if (Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl)) {
return decl.getName()
}
return null
}
// For enum types
mapEnumField(enumType: Type, propertyName: string): IPortConfig {
const enumName = this.extractTypeName(enumType)
if (enumName) {
this.discoveredTypes.add(enumName)
// Get first enum value as default
const enumDecl = this.findEnumDeclaration(enumName)
const firstMember = enumDecl?.getMembers()[0]
const defaultValue = firstMember ? `${enumName}.${firstMember.getName()}` : undefined
return {
type: 'enum-native', // New type!
title: this.formatTitle(propertyName),
enumType: enumName,
defaultValue,
}
}
// Fallback to inline enum options
return this.mapInlineEnum(enumType, propertyName)
}
}Task 5: Update TemplateEngine
File: TemplateEngine.ts
New Methods:
typescript
/**
* Generate plain TypeScript enum (NO @ObjectSchema!)
*/
generateEnum(metadata: TypeMetadata): string {
const enumDecl = metadata.declaration as EnumDeclaration
const members = enumDecl.getMembers()
const jsDocs = enumDecl.getJsDocs()
const description = this.extractDescription(jsDocs)
const comment = description ? `/** ${description} */\n` : ''
const memberCode = members.map(member => {
const name = member.getName()
const value = member.getValue()
const memberDoc = member.getJsDocs()[0]?.getComment()
const memberComment = memberDoc ? ` /** ${memberDoc} */\n ` : ' '
return `${memberComment}${name} = "${value}"`
}).join(',\n')
return `${comment}export enum ${metadata.name} {\n${memberCode}\n}`
}
/**
* Generate object schema with type references
*/
generateObjectSchema(
metadata: TypeMetadata,
allTypes: Map<string, TypeMetadata>
): string {
const interfaceDecl = metadata.declaration as InterfaceDeclaration
const properties = interfaceDecl.getProperties()
const propertyCode = properties.map(prop => {
const name = prop.getName()
const type = prop.getType()
const optional = prop.hasQuestionToken()
// Extract property metadata
const portConfig = this.typeMapper.mapType(type, name, {
collectDependencies: true,
allTypes
})
// Special handling for enum types
if (this.isEnumType(type, allTypes)) {
return this.generateEnumProperty(prop, type, allTypes)
}
// Special handling for object types
if (this.isObjectType(type, allTypes)) {
return this.generateObjectProperty(prop, type, allTypes)
}
// Special handling for array types
if (type.isArray()) {
return this.generateArrayProperty(prop, type, allTypes)
}
// Default property generation
return this.generatePropertyDeclaration({
name,
portConfig,
description: this.extractDescription(prop.getJsDocs()),
optional,
})
}).join('\n\n')
return `
@ObjectSchema({
description: '${this.extractDescription(interfaceDecl.getJsDocs())}',
type: '${metadata.name}',
})
export class ${metadata.name} {
${this.indent(propertyCode, 2)}
}
`.trim()
}
/**
* Generate enum property using @PortEnumFromNative
*/
private generateEnumProperty(
prop: PropertySignature,
type: Type,
allTypes: Map<string, TypeMetadata>
): string {
const name = prop.getName()
const optional = prop.hasQuestionToken()
const enumTypeName = this.extractTypeName(type)
const description = this.extractDescription(prop.getJsDocs())
// Get default value (first enum member)
const enumMeta = allTypes.get(enumTypeName)
const firstMember = (enumMeta?.declaration as EnumDeclaration)?.getMembers()[0]
const defaultValue = firstMember ? `${enumTypeName}.${firstMember.getName()}` : undefined
const comment = description ? this.formatJSDocComment(description, 2) : ''
return `${comment} @PortEnumFromNative(${enumTypeName}, {
title: '${this.formatTitle(name)}',
description: '${this.escapeString(description || '')}',
${defaultValue ? `defaultValue: ${defaultValue},` : ''}
})
${name}${optional ? '?' : ''}: ${enumTypeName}`
}
/**
* Generate array property with schema reference
*/
private generateArrayProperty(
prop: PropertySignature,
type: Type,
allTypes: Map<string, TypeMetadata>
): string {
const name = prop.getName()
const optional = prop.hasQuestionToken()
const elementType = type.getArrayElementType()!
const description = this.extractDescription(prop.getJsDocs())
const comment = description ? this.formatJSDocComment(description, 2) : ''
// Check if element is an object type
const elementTypeName = this.extractTypeName(elementType)
if (elementTypeName && allTypes.has(elementTypeName)) {
// Reference to generated schema
return `${comment} @PortArray({
title: '${this.formatTitle(name)}',
description: '${this.escapeString(description || '')}',
defaultValue: [],
itemConfig: {
type: 'object',
schema: ${elementTypeName}, // ✅ Class reference!
},
isMutable: true,
})
${name}${optional ? '?' : ''}: ${elementTypeName}[]` // ✅ Typed array!
}
// Fallback to primitive array
const itemConfig = this.typeMapper.mapType(elementType, `${name}_item`)
return `${comment} @PortArray({
title: '${this.formatTitle(name)}',
description: '${this.escapeString(description || '')}',
defaultValue: [],
itemConfig: ${JSON.stringify(itemConfig)},
isMutable: true,
})
${name}${optional ? '?' : ''}: ${this.getTypeScriptType(type)}`
}Task 6: Combine Schemas
File: TemplateEngine.ts
typescript
combineSchemas(schemas: GeneratedSchema[]): string {
// Collect used decorators
const usedDecorators = new Set<string>()
usedDecorators.add('ObjectSchema')
for (const schema of schemas) {
if (schema.kind === 'object-schema') {
// Scan code for used decorators
if (schema.code.includes('@PortEnumFromNative')) usedDecorators.add('PortEnumFromNative')
if (schema.code.includes('@PortNumber')) usedDecorators.add('PortNumber')
if (schema.code.includes('@PortString')) usedDecorators.add('PortString')
if (schema.code.includes('@PortArray')) usedDecorators.add('PortArray')
if (schema.code.includes('@PortObject')) usedDecorators.add('PortObject')
if (schema.code.includes('@PortBoolean')) usedDecorators.add('PortBoolean')
}
}
const imports = `import { ${Array.from(usedDecorators).sort().join(', ')} } from '@badaitech/chaingraph-types'`
// Group schemas by kind
const enums = schemas.filter(s => s.kind === 'enum')
const objects = schemas.filter(s => s.kind === 'object-schema')
const enumsSection = enums.length > 0 ? `
// ==========================================
// ENUMS
// ==========================================
${enums.map(s => s.code).join('\n\n')}
` : ''
const objectsSection = objects.length > 0 ? `
// ==========================================
// OBJECT SCHEMAS
// ==========================================
${objects.map(s => s.code).join('\n\n')}
` : ''
return `${imports}\n${enumsSection}${objectsSection}`.trim()
}Summary
This plan will transform the generator to produce clean, typed, reusable schemas that follow ChainGraph patterns exactly:
✅ Enums as plain TypeScript (no decorators) ✅ @PortEnumFromNative for enum fields ✅ @PortArray with schema references for object arrays ✅ Separate schemas for each type ✅ Full type preservation ✅ Proper dependency ordering
Ready to implement?