/** * JSON Schema to Effect Schema Converter * * Converts JSON Schema to Effect Schema TypeScript code with proper * handling of recursive types using S.suspend() and interface definitions. */ // ============================================================================ // Types // ============================================================================ type JsonSchema = { $ref?: string; $defs?: Record; definitions?: Record; type?: string | Array; properties?: Record; required?: Array; items?: JsonSchema; enum?: Array; const?: unknown; oneOf?: Array; anyOf?: Array; allOf?: Array; additionalProperties?: boolean | JsonSchema; title?: string; description?: string; default?: unknown; nullable?: boolean; }; type TypeDefinition = { name: string; schema: JsonSchema; isRecursive: boolean; dependencies: Set; }; type ConversionContext = { definitions: Map; recursiveTypes: Set; currentPath: Array; }; // ============================================================================ // Utilities // ============================================================================ /** * Convert a string to PascalCase for type names */ function toPascalCase(str: string): string { return str .split(/[-_\s]+/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); } /** * Extract the definition name from a $ref */ function getRefName(ref: string): string { const parts = ref.split('/'); return parts.at(-1) ?? ''; } /** * Check if a schema is nullable (has null in type array, nullable: true, or null in oneOf/anyOf) */ function isNullable(schema: JsonSchema): boolean { if (schema.nullable) return true; if (Array.isArray(schema.type) && schema.type.includes('null')) return true; // Check for null in oneOf/anyOf const items = schema.oneOf || schema.anyOf; if (items) { return items.some( (s) => s.type === 'null' || ('const' in s && s.const === null), ); } return false; } /** * Get the non-null type from a schema */ function getNonNullType(schema: JsonSchema): string | undefined { if (Array.isArray(schema.type)) { const nonNull = schema.type.filter((t) => t !== 'null'); return nonNull.length === 1 ? nonNull[0] : undefined; } return schema.type; } // ============================================================================ // Schema Analysis // ============================================================================ /** * Collect all definitions from the schema */ function collectDefinitions(schema: JsonSchema): Map { const definitions = new Map(); const defs = schema.$defs || schema.definitions || {}; for (const [name, defSchema] of Object.entries(defs)) { definitions.set(name, { name: toPascalCase(name), schema: defSchema, isRecursive: false, dependencies: new Set(), }); } return definitions; } /** * Find all $ref dependencies in a schema */ function findDependencies(schema: JsonSchema): Set { const deps = new Set(); function traverse(s: JsonSchema): void { if (s.$ref) { deps.add(getRefName(s.$ref)); } if (s.properties) { for (const propSchema of Object.values(s.properties)) { traverse(propSchema); } } if (s.items) { traverse(s.items); } if (s.oneOf) { for (const item of s.oneOf) { traverse(item); } } if (s.anyOf) { for (const item of s.anyOf) { traverse(item); } } if (s.allOf) { for (const item of s.allOf) { traverse(item); } } if (typeof s.additionalProperties === 'object' && s.additionalProperties) { traverse(s.additionalProperties); } } traverse(schema); return deps; } /** * Detect recursive types using DFS cycle detection */ function detectRecursiveTypes( definitions: Map, ): Set { const recursiveTypes = new Set(); // Build dependency graph for (const [, def] of definitions) { def.dependencies = findDependencies(def.schema); } // DFS to detect cycles function hasCycle( name: string, visiting: Set, visited: Set, ): boolean { if (visiting.has(name)) { return true; } if (visited.has(name)) { return false; } visiting.add(name); const def = definitions.get(name); if (def) { for (const dep of def.dependencies) { if (hasCycle(dep, visiting, visited)) { recursiveTypes.add(name); recursiveTypes.add(dep); } } } visiting.delete(name); visited.add(name); return false; } // Check each definition for cycles for (const name of definitions.keys()) { hasCycle(name, new Set(), new Set()); } // Also check for direct self-references for (const [name, def] of definitions) { if (def.dependencies.has(name)) { recursiveTypes.add(name); def.isRecursive = true; } } // Mark all recursive types for (const name of recursiveTypes) { const def = definitions.get(name); if (def) { def.isRecursive = true; } } return recursiveTypes; } /** * Topologically sort definitions (dependencies first) */ function sortDefinitions( definitions: Map, recursiveTypes: Set, ): Array { const sorted: Array = []; const visited = new Set(); function visit(name: string): void { if (visited.has(name)) return; visited.add(name); const def = definitions.get(name); if (!def) return; // Visit non-recursive dependencies first for (const dep of def.dependencies) { if (!(recursiveTypes.has(dep) && recursiveTypes.has(name))) { visit(dep); } } sorted.push(def); } for (const name of definitions.keys()) { visit(name); } return sorted; } // ============================================================================ // Code Generation // ============================================================================ /** * Generate TypeScript type for a JSON Schema (used for recursive type declarations) */ function generateTsType(schema: JsonSchema, _ctx: ConversionContext): string { if (schema.$ref) { const refName = getRefName(schema.$ref); const pascalName = toPascalCase(refName); return pascalName; } const type = getNonNullType(schema); if (schema.enum) { return schema.enum.map((v) => JSON.stringify(v)).join(' | '); } if (schema.const !== undefined) { return JSON.stringify(schema.const); } if (schema.oneOf || schema.anyOf) { const items = schema.oneOf || schema.anyOf || []; const types = items .filter((s) => s.type !== 'null' && !('const' in s && s.const === null)) .map((s) => generateTsType(s, _ctx)); return types.join(' | '); } switch (type) { case 'string': return 'string'; case 'number': case 'integer': return 'number'; case 'boolean': return 'boolean'; case 'null': return 'null'; case 'array': if (schema.items) { return `ReadonlyArray<${generateTsType(schema.items, _ctx)}>`; } return 'ReadonlyArray'; case 'object': if (schema.properties) { const props = Object.entries(schema.properties) .map(([key, propSchema]) => { const isRequired = schema.required?.includes(key) ?? false; const nullable = isNullable(propSchema); const tsType = generateTsType(propSchema, _ctx); const nullSuffix = nullable ? ' | null' : ''; const optionalMark = isRequired ? '' : '?'; return ` readonly "${key}"${optionalMark}: ${tsType}${nullSuffix}`; }) .join(';\n'); return `{\n${props}\n}`; } if ( schema.additionalProperties === true || typeof schema.additionalProperties === 'object' ) { const valueType = typeof schema.additionalProperties === 'object' ? generateTsType(schema.additionalProperties, _ctx) : 'unknown'; return `Record`; } return 'Record'; default: return 'unknown'; } } /** * Generate TypeScript interface/type declaration for a recursive type */ function generateTypeDeclaration( name: string, schema: JsonSchema, ctx: ConversionContext, ): string { const tsType = generateTsType(schema, ctx); if (tsType.startsWith('{')) { return `interface ${name} ${tsType}`; } return `type ${name} = ${tsType}`; } /** * Generate Effect Schema code for a JSON Schema */ function generateEffectSchema( schema: JsonSchema, ctx: ConversionContext, ): string { // Handle $ref if (schema.$ref) { const refName = getRefName(schema.$ref); const pascalName = toPascalCase(refName); // Check if this is a recursive reference - use S.suspend with explicit return type if (ctx.recursiveTypes.has(refName)) { return `S.suspend((): S.Schema<${pascalName}> => ${pascalName})`; } return pascalName; } // Handle nullable types const nullable = isNullable(schema); const type = getNonNullType(schema); // Handle enum if (schema.enum) { const literals = schema.enum.map((v) => JSON.stringify(v)).join(', '); const result = `S.Literal(${literals})`; return nullable ? `S.NullOr(${result})` : result; } // Handle const if (schema.const !== undefined) { const result = `S.Literal(${JSON.stringify(schema.const)})`; return nullable ? `S.NullOr(${result})` : result; } // Handle oneOf / anyOf (union) if (schema.oneOf || schema.anyOf) { const items = schema.oneOf || schema.anyOf || []; const nonNullItems = items.filter( (s) => s.type !== 'null' && !('const' in s && s.const === null), ); if (nonNullItems.length === 0) { return 'S.Null'; } if (nonNullItems.length === 1) { const firstItem = nonNullItems[0]; if (!firstItem) return 'S.Unknown'; const innerSchema = generateEffectSchema(firstItem, ctx); const hasNull = items.some( (s) => s.type === 'null' || ('const' in s && s.const === null), ); return hasNull ? `S.NullOr(${innerSchema})` : innerSchema; } const members = nonNullItems .map((s) => generateEffectSchema(s, ctx)) .join(', '); const hasNull = items.some( (s) => s.type === 'null' || ('const' in s && s.const === null), ); return hasNull ? `S.Union(${members}, S.Null)` : `S.Union(${members})`; } // Handle allOf (intersection - we merge properties) if (schema.allOf) { // For simplicity, merge all schemas' properties const merged: JsonSchema = { type: 'object', properties: {}, required: [] }; for (const item of schema.allOf) { if (item.properties) { merged.properties = { ...merged.properties, ...item.properties }; } if (item.required) { merged.required = [...(merged.required || []), ...item.required]; } } return generateEffectSchema(merged, ctx); } // Handle by type switch (type) { case 'string': { const result = 'S.String'; return nullable ? `S.NullOr(${result})` : result; } case 'number': case 'integer': { const result = 'S.Number'; return nullable ? `S.NullOr(${result})` : result; } case 'boolean': { const result = 'S.Boolean'; return nullable ? `S.NullOr(${result})` : result; } case 'null': return 'S.Null'; case 'array': { const itemSchema = schema.items ? generateEffectSchema(schema.items, ctx) : 'S.Unknown'; const result = `S.Array(${itemSchema})`; return nullable ? `S.NullOr(${result})` : result; } case 'object': { if (schema.properties) { const props = Object.entries(schema.properties) .map(([key, propSchema]) => { const isRequired = schema.required?.includes(key) ?? false; const propNullable = isNullable(propSchema); let propCode = generateEffectSchema(propSchema, ctx); // Wrap in optional if not required if (!isRequired) { if (propNullable && !propCode.startsWith('S.NullOr(')) { propCode = `S.NullOr(${propCode})`; } propCode = `S.optional(${propCode})`; } return ` "${key}": ${propCode}`; }) .join(',\n'); const result = `S.Struct({\n${props}\n})`; return nullable ? `S.NullOr(${result})` : result; } if ( schema.additionalProperties === true || typeof schema.additionalProperties === 'object' ) { const valueSchema = typeof schema.additionalProperties === 'object' ? generateEffectSchema(schema.additionalProperties, ctx) : 'S.Unknown'; const result = `S.Record({ key: S.String, value: ${valueSchema} })`; return nullable ? `S.NullOr(${result})` : result; } // Empty object or any object const result = 'S.Record({ key: S.String, value: S.Unknown })'; return nullable ? `S.NullOr(${result})` : result; } default: // Unknown or any type return nullable ? 'S.NullOr(S.Unknown)' : 'S.Unknown'; } } /** * Generate non-recursive schema definition (schema + derived type) */ function generateNonRecursiveDefinition( def: TypeDefinition, ctx: ConversionContext, ): string { const lines: Array = []; const { name, schema } = def; // Add description as JSDoc comment if (schema.description) { lines.push(`/** ${schema.description} */`); } // Check if it's an enum type if (schema.enum) { const literals = schema.enum.map((v) => JSON.stringify(v)).join(',\n '); lines.push(`export const ${name} = S.Literal(\n ${literals}\n);`); lines.push(`export type ${name} = S.Schema.Type;`); return lines.join('\n'); } // Generate the schema const schemaCode = generateEffectSchema(schema, ctx); lines.push(`export const ${name} = ${schemaCode};`); lines.push(`export type ${name} = S.Schema.Type;`); return lines.join('\n'); } /** * Generate recursive schema definition (schema with type assertion to bypass structural checks) */ function generateRecursiveSchemaDefinition( def: TypeDefinition, ctx: ConversionContext, ): string { const lines: Array = []; const { name, schema } = def; // Add description as JSDoc comment if (schema.description) { lines.push(`/** ${schema.description} */`); } // Generate the schema with double type assertion to bypass structural checking const schemaCode = generateEffectSchema(schema, ctx); lines.push( `export const ${name} = ${schemaCode} as unknown as S.Schema<${name}>;`, ); return lines.join('\n'); } // ============================================================================ // Main Conversion Function // ============================================================================ export type ConvertOptions = { /** The JSON Schema to convert */ schema: JsonSchema; /** The name for the root type (if the schema has no $ref) */ rootName?: string; }; export type ConvertResult = { /** The generated TypeScript code */ code: string; /** Names of all generated types */ typeNames: Array; /** Names of recursive types */ recursiveTypes: Array; }; /** * Convert a JSON Schema to Effect Schema TypeScript code */ export function convert(options: ConvertOptions): ConvertResult { const { schema, rootName = 'Root' } = options; // Collect all definitions const definitions = collectDefinitions(schema); // If the root schema is not a $ref, add it as a definition if (!schema.$ref && (schema.type === 'object' || schema.properties)) { definitions.set(rootName, { name: toPascalCase(rootName), schema, isRecursive: false, dependencies: new Set(), }); } // Detect recursive types const recursiveTypes = detectRecursiveTypes(definitions); // Create conversion context const ctx: ConversionContext = { definitions, recursiveTypes, currentPath: [], }; // Sort definitions topologically const sorted = sortDefinitions(definitions, recursiveTypes); // Split into non-recursive and recursive definitions const nonRecursive = sorted.filter((d) => !recursiveTypes.has(d.name)); const recursive = sorted.filter((d) => recursiveTypes.has(d.name)); // Generate code const lines: Array = [ '// This file is auto-generated. Do not edit manually.', "import * as S from 'effect/Schema';", '', ]; // 1. Generate non-recursive definitions (schema + type) for (const def of nonRecursive) { lines.push(generateNonRecursiveDefinition(def, ctx)); lines.push(''); } // 2. Generate type declarations for recursive types (before schemas) if (recursive.length > 0) { lines.push('// Recursive type declarations'); for (const def of recursive) { lines.push(generateTypeDeclaration(def.name, def.schema, ctx)); } lines.push(''); // 3. Generate schema definitions for recursive types lines.push('// Recursive schema definitions'); for (const def of recursive) { lines.push(generateRecursiveSchemaDefinition(def, ctx)); lines.push(''); } } // Handle root $ref if (schema.$ref) { const refName = toPascalCase(getRefName(schema.$ref)); const exportName = toPascalCase(rootName); if (refName !== exportName) { lines.push(`export { ${refName} as ${exportName} };`); lines.push(''); } } return { code: lines.join('\n'), typeNames: sorted.map((d) => d.name), recursiveTypes: Array.from(recursiveTypes).map(toPascalCase), }; } /** * Convert a JSON Schema string to Effect Schema TypeScript code */ export function convertFromString( jsonSchemaString: string, rootName = 'Root', ): ConvertResult { const schema = JSON.parse(jsonSchemaString) as JsonSchema; return convert({ schema, rootName }); }