325 lines
11 KiB
Python
325 lines
11 KiB
Python
#!/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<T>
|
|
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<K, V>
|
|
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<typeof {name}Schema>;')
|
|
|
|
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<typeof {name}Schema>;'
|
|
]
|
|
|
|
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<typeof {name}Schema>;'
|
|
]
|
|
|
|
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())
|