Files
winos-config/packages/dsc-ts/scripts/lib/json-schema-to-effect.ts
EthanShoeDev ac51644bda wip monorepo
2025-12-22 15:37:29 -05:00

685 lines
18 KiB
TypeScript

/**
* 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<string, JsonSchema>;
definitions?: Record<string, JsonSchema>;
type?: string | Array<string>;
properties?: Record<string, JsonSchema>;
required?: Array<string>;
items?: JsonSchema;
enum?: Array<unknown>;
const?: unknown;
oneOf?: Array<JsonSchema>;
anyOf?: Array<JsonSchema>;
allOf?: Array<JsonSchema>;
additionalProperties?: boolean | JsonSchema;
title?: string;
description?: string;
default?: unknown;
nullable?: boolean;
};
type TypeDefinition = {
name: string;
schema: JsonSchema;
isRecursive: boolean;
dependencies: Set<string>;
};
type ConversionContext = {
definitions: Map<string, TypeDefinition>;
recursiveTypes: Set<string>;
currentPath: Array<string>;
};
// ============================================================================
// 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 represents null
*/
function isJsonSchemaNull(s: JsonSchema): boolean {
return (
typeof s === 'object' &&
s !== null &&
(s.type === 'null' || ('const' in s && s.const === null))
);
}
/**
* Check if a schema is nullable (has null in type array, nullable: true, or null in oneOf/anyOf)
*/
function isNullable(schema: JsonSchema): boolean {
if (typeof schema !== 'object' || schema === null) return false;
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(isJsonSchemaNull);
}
return false;
}
/**
* Get the non-null type from a schema
*/
function getNonNullType(schema: JsonSchema): string | undefined {
if (typeof schema !== 'object' || schema === null) return;
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<string, TypeDefinition> {
const definitions = new Map<string, TypeDefinition>();
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<string> {
const deps = new Set<string>();
function traverse(s: JsonSchema): void {
if (typeof s !== 'object' || s === null) return;
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<string, TypeDefinition>,
): Set<string> {
const recursiveTypes = new Set<string>();
// Build dependency graph
for (const [, def] of definitions) {
def.dependencies = findDependencies(def.schema);
}
// DFS to detect cycles
function hasCycle(
name: string,
visiting: Set<string>,
visited: Set<string>,
): 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<string, TypeDefinition>,
recursiveTypes: Set<string>,
): Array<TypeDefinition> {
const sorted: Array<TypeDefinition> = [];
const visited = new Set<string>();
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 (typeof schema === 'boolean') {
return schema ? 'any' : 'never';
}
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) => !isJsonSchemaNull(s))
.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<unknown>';
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<string, ${valueType}>`;
}
return 'Record<string, unknown>';
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 {
if (typeof schema === 'boolean') {
return schema ? 'S.Unknown' : 'S.Never';
}
// 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) => !isJsonSchemaNull(s));
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(isJsonSchemaNull);
return hasNull ? `S.NullOr(${innerSchema})` : innerSchema;
}
const members = nonNullItems
.map((s) => generateEffectSchema(s, ctx))
.join(', ');
const hasNull = items.some(isJsonSchemaNull);
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<string> = [];
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<typeof ${name}>;`);
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<typeof ${name}>;`);
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<string> = [];
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<string>;
/** Names of recursive types */
recursiveTypes: Array<string>;
};
/**
* 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<string> = [
'// 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 });
}