#!/usr/bin/env python3 """ Generate Zod schemas from TypeScript interfaces/types. Usage: python generate_zod_schema.py --input types/entities.ts --output schemas/entities.ts python generate_zod_schema.py --input-string "interface User { name: string; age: number; }" """ import re import argparse from pathlib import Path from typing import Dict, List, Optional, Tuple class TypeScriptToZodConverter: """Convert TypeScript types to Zod schemas.""" # Type mappings from TS to Zod TYPE_MAP = { 'string': 'z.string()', 'number': 'z.number()', 'boolean': 'z.boolean()', 'Date': 'z.date()', 'any': 'z.any()', 'unknown': 'z.unknown()', 'null': 'z.null()', 'undefined': 'z.undefined()', 'void': 'z.void()', } def __init__(self): self.interfaces: Dict[str, str] = {} self.types: Dict[str, str] = {} self.enums: Dict[str, List[str]] = {} def convert_type(self, ts_type: str, optional: bool = False) -> str: """Convert a TypeScript type to Zod schema.""" ts_type = ts_type.strip() # Handle optional (?) if ts_type.endswith('?'): ts_type = ts_type[:-1].strip() optional = True # Handle union with undefined/null if ' | undefined' in ts_type or ' | null' in ts_type: optional = True ts_type = re.sub(r'\s*\|\s*(undefined|null)', '', ts_type).strip() # Simple types if ts_type in self.TYPE_MAP: zod = self.TYPE_MAP[ts_type] # Array types elif ts_type.endswith('[]'): inner_type = ts_type[:-2].strip() inner_zod = self.convert_type(inner_type) zod = f'z.array({inner_zod})' # Generic Array elif ts_type.startswith('Array<') and ts_type.endswith('>'): inner_type = ts_type[6:-1].strip() inner_zod = self.convert_type(inner_type) zod = f'z.array({inner_zod})' # Record elif ts_type.startswith('Record<'): match = re.match(r'Record<\s*(.+?)\s*,\s*(.+?)\s*>', ts_type) if match: value_type = match.group(2) value_zod = self.convert_type(value_type) zod = f'z.record({value_zod})' else: zod = 'z.record(z.any())' # Union types elif ' | ' in ts_type: union_types = [t.strip() for t in ts_type.split('|')] union_zods = [self.convert_type(t) for t in union_types] zod = f'z.union([{", ".join(union_zods)}])' # Literal types elif ts_type.startswith("'") or ts_type.startswith('"'): zod = f'z.literal({ts_type})' # Reference to another interface/type/enum elif ts_type in self.interfaces or ts_type in self.types or ts_type in self.enums: zod = f'{ts_type}Schema' else: # Unknown type - use z.any() with comment zod = f'z.any() /* TODO: define schema for {ts_type} */' # Add optional modifier if optional: zod = f'{zod}.optional()' return zod def parse_interface(self, interface_str: str) -> Tuple[str, List[Tuple[str, str, bool, Optional[str]]]]: """Parse a TypeScript interface and return name and fields.""" # Extract interface name name_match = re.search(r'interface\s+(\w+)', interface_str) if not name_match: raise ValueError("Could not parse interface name") name = name_match.group(1) # Extract fields fields = [] field_pattern = re.compile( r'^\s*(?:/\*\*\s*(.+?)\s*\*/\s*)?' # Optional JSDoc comment r'(\w+)(\??)\s*:\s*(.+?);?\s*$', # fieldName?: type; re.MULTILINE ) body_match = re.search(r'\{(.+?)\}', interface_str, re.DOTALL) if body_match: body = body_match.group(1) for match in field_pattern.finditer(body): description = match.group(1) field_name = match.group(2) optional = match.group(3) == '?' field_type = match.group(4).strip() fields.append((field_name, field_type, optional, description)) return name, fields def parse_type_alias(self, type_str: str) -> Tuple[str, str]: """Parse a TypeScript type alias.""" match = re.search(r'type\s+(\w+)\s*=\s*(.+?);?\s*$', type_str, re.DOTALL) if not match: raise ValueError("Could not parse type alias") name = match.group(1) type_def = match.group(2).strip().rstrip(';') return name, type_def def parse_enum(self, enum_str: str) -> Tuple[str, List[str]]: """Parse a TypeScript enum.""" name_match = re.search(r'enum\s+(\w+)', enum_str) if not name_match: raise ValueError("Could not parse enum name") name = name_match.group(1) # Extract enum values values = [] body_match = re.search(r'\{(.+?)\}', enum_str, re.DOTALL) if body_match: body = body_match.group(1) value_pattern = re.compile(r'(\w+)\s*(?:=\s*["\'](.+?)["\']\s*)?', re.MULTILINE) for match in value_pattern.finditer(body): value = match.group(2) if match.group(2) else match.group(1) values.append(value) return name, values def interface_to_zod(self, name: str, fields: List[Tuple[str, str, bool, Optional[str]]]) -> str: """Convert interface fields to Zod schema.""" schema_lines = [f'export const {name}Schema = z.object({{'] for field_name, field_type, optional, description in fields: zod_type = self.convert_type(field_type, optional) # Add description if present if description: desc_line = f' {field_name}: {zod_type}.describe("{description}"),' else: desc_line = f' {field_name}: {zod_type},' schema_lines.append(desc_line) schema_lines.append('});') schema_lines.append('') schema_lines.append(f'export type {name} = z.infer;') return '\n'.join(schema_lines) def type_alias_to_zod(self, name: str, type_def: str) -> str: """Convert type alias to Zod schema.""" zod_type = self.convert_type(type_def) lines = [ f'export const {name}Schema = {zod_type};', '', f'export type {name} = z.infer;' ] return '\n'.join(lines) def enum_to_zod(self, name: str, values: List[str]) -> str: """Convert enum to Zod schema.""" quoted_values = [f'"{v}"' for v in values] if len(values) == 1: zod_def = f'z.literal({quoted_values[0]})' else: zod_def = f'z.enum([{", ".join(quoted_values)}])' lines = [ f'export const {name}Schema = {zod_def};', '', f'export type {name} = z.infer;' ] return '\n'.join(lines) def convert_file(self, input_content: str) -> str: """Convert an entire TypeScript file to Zod schemas.""" output_lines = [ "import { z } from 'zod';", '', '// Auto-generated Zod schemas', '' ] # Parse all interfaces interface_pattern = re.compile( r'(?:export\s+)?interface\s+\w+\s*\{[^}]*\}', re.DOTALL ) for match in interface_pattern.finditer(input_content): interface_str = match.group(0) try: name, fields = self.parse_interface(interface_str) self.interfaces[name] = interface_str except ValueError: pass # Parse all type aliases type_pattern = re.compile( r'(?:export\s+)?type\s+\w+\s*=\s*[^;]+;?', re.DOTALL ) for match in type_pattern.finditer(input_content): type_str = match.group(0) try: name, type_def = self.parse_type_alias(type_str) self.types[name] = type_def except ValueError: pass # Parse all enums enum_pattern = re.compile( r'(?:export\s+)?enum\s+\w+\s*\{[^}]*\}', re.DOTALL ) for match in enum_pattern.finditer(input_content): enum_str = match.group(0) try: name, values = self.parse_enum(enum_str) self.enums[name] = values except ValueError: pass # Generate Zod schemas # Enums first (no dependencies) for name, values in self.enums.items(): output_lines.append(self.enum_to_zod(name, values)) output_lines.append('') # Then type aliases for name, type_def in self.types.items(): output_lines.append(self.type_alias_to_zod(name, type_def)) output_lines.append('') # Finally interfaces for name, interface_str in self.interfaces.items(): _, fields = self.parse_interface(interface_str) output_lines.append(self.interface_to_zod(name, fields)) output_lines.append('') return '\n'.join(output_lines) def main(): parser = argparse.ArgumentParser( description='Generate Zod schemas from TypeScript interfaces/types' ) parser.add_argument( '--input', help='Input TypeScript file path' ) parser.add_argument( '--output', help='Output file path for Zod schemas' ) parser.add_argument( '--input-string', help='Input TypeScript code as string' ) args = parser.parse_args() # Read input if args.input: input_path = Path(args.input) if not input_path.exists(): print(f"Error: Input file not found: {args.input}") return 1 input_content = input_path.read_text(encoding='utf-8') elif args.input_string: input_content = args.input_string else: print("Error: Either --input or --input-string must be provided") return 1 # Convert converter = TypeScriptToZodConverter() try: output_content = converter.convert_file(input_content) except Exception as e: print(f"Error during conversion: {e}") return 1 # Write output if args.output: output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(output_content, encoding='utf-8') print(f"Generated Zod schemas written to: {args.output}") else: print(output_content) return 0 if __name__ == '__main__': exit(main())