/* eslint-disable @typescript-eslint/no-explicit-any */ import type { Schema } from './base.ts'; import { SCHEMA_KIND } from './base.ts'; import { string } from './primitives/string.ts'; import { number } from './primitives/number.ts'; import { boolean } from './primitives/boolean.ts'; import { null_ } from './primitives/null.ts'; import { object } from './complex/object.ts'; import { array } from './complex/array.ts'; import { literal } from './utils/literal.ts'; import { optional } from './utils/optional.ts'; import { nullable } from './utils/nullable.ts'; import { union } from './utils/union.ts'; /** * Check schema type using a minification-safe SCHEMA_KIND tag. * This works across bundled modules and after minification because it uses * a Symbol.for() based tag rather than constructor.name which gets mangled. */ function isSchemaType(schema: any, typeName: string): boolean { return schema?.[SCHEMA_KIND] === typeName; } /** * JSON Schema object representation. * Subset of JSON Schema Draft 7 specification. */ export interface JSONSchema { type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; description?: string; const?: string | number | boolean; enum?: Array; properties?: Record; required?: string[]; items?: JSONSchema; anyOf?: JSONSchema[]; oneOf?: JSONSchema[]; allOf?: JSONSchema[]; additionalProperties?: JSONSchema | boolean; } /** * Options for toJSONSchema conversion. */ export interface ToJSONSchemaOptions { /** * When true, adds `additionalProperties: false` to all object schemas. * Required for LLM structured output compatibility (OpenAI, Groq, etc.). * @default false */ strict?: boolean; } /** * Convert a schema to JSON Schema format. * Supports primitives, objects, arrays, unions, literals, optional, and nullable types. * * @param schema - The schema to convert * @param options - Conversion options * @returns JSON Schema object * * @example * ```typescript * const userSchema = s.object({ * name: s.string().describe('User name'), * age: s.number().describe('User age') * }); * * const jsonSchema = s.toJSONSchema(userSchema); * // { type: 'object', properties: {...}, required: [...] } * * // For LLM structured output compatibility (OpenAI, Groq, etc.): * const strictSchema = s.toJSONSchema(userSchema, { strict: true }); * // { type: 'object', properties: {...}, required: [...], additionalProperties: false } * ``` */ export function toJSONSchema(schema: Schema, options?: ToJSONSchemaOptions): JSONSchema { const result: JSONSchema = {}; // Add description if available if (schema.description) { result.description = schema.description; } // Primitive types - use constructor name checks (works across bundled modules) if (isSchemaType(schema, 'StringSchema') || isSchemaType(schema, 'CoerceStringSchema')) { result.type = 'string'; return result; } if (isSchemaType(schema, 'NumberSchema') || isSchemaType(schema, 'CoerceNumberSchema')) { result.type = 'number'; return result; } if (isSchemaType(schema, 'BooleanSchema') || isSchemaType(schema, 'CoerceBooleanSchema')) { result.type = 'boolean'; return result; } if (isSchemaType(schema, 'NullSchema')) { result.type = 'null'; return result; } if (isSchemaType(schema, 'UndefinedSchema')) { // JSON Schema doesn't have a direct "undefined" type // We can represent it as an empty schema or omit the field return {}; } // Literal types if (isSchemaType(schema, 'LiteralSchema')) { const value = (schema as any).value; result.const = value; if (typeof value === 'string') { result.type = 'string'; } else if (typeof value === 'number') { result.type = 'number'; } else if (typeof value === 'boolean') { result.type = 'boolean'; } return result; } // Object types if (isSchemaType(schema, 'ObjectSchema')) { result.type = 'object'; const shape = (schema as any).shape; result.properties = {}; result.required = []; for (const [key, fieldSchema] of Object.entries(shape) as Array<[string, Schema]>) { result.properties[key] = toJSONSchema(fieldSchema, options); // If the field is not optional, add it to required if (!isSchemaType(fieldSchema, 'OptionalSchema')) { result.required.push(key); } } // Remove required if empty if (result.required.length === 0) { delete result.required; } if (options?.strict) { result.additionalProperties = false; } return result; } // Array types if (isSchemaType(schema, 'ArraySchema')) { result.type = 'array'; const itemSchema = (schema as any).itemSchema; result.items = toJSONSchema(itemSchema, options); return result; } // Optional types if (isSchemaType(schema, 'OptionalSchema')) { const innerSchema = (schema as any).schema; const innerJSON = toJSONSchema(innerSchema, options); // Optional is typically handled at the object level via required array return innerJSON; } // Nullable types if (isSchemaType(schema, 'NullableSchema')) { const innerSchema = (schema as any).schema; const innerJSON = toJSONSchema(innerSchema, options); // Nullable can be represented as anyOf with null return { anyOf: [innerJSON, { type: 'null' }], ...(schema.description && { description: schema.description }), }; } // Union types if (isSchemaType(schema, 'UnionSchema')) { const schemas = (schema as any).schemas as Schema[]; result.anyOf = schemas.map((schema) => toJSONSchema(schema, options)); return result; } // Record types (object with string keys and typed values) if (isSchemaType(schema, 'RecordSchema')) { result.type = 'object'; // Record schemas have additionalProperties const valueSchema = (schema as any).valueSchema; if (valueSchema) { (result as any).additionalProperties = toJSONSchema(valueSchema, options); } return result; } // Unknown/Any types - accept anything if (isSchemaType(schema, 'UnknownSchema') || isSchemaType(schema, 'AnySchema')) { // Return empty schema (accepts any value in JSON Schema) return result; } // Coerce date - represented as string in JSON Schema if (isSchemaType(schema, 'CoerceDateSchema')) { result.type = 'string'; (result as any).format = 'date-time'; return result; } // Fallback for unknown schema types return result; } /** * Convert a JSON Schema object to a schema. * Supports round-trip conversion with toJSONSchema. * * @param jsonSchema - The JSON Schema object to convert * @returns Schema instance * * @example * ```typescript * const jsonSchema = { * type: 'object', * properties: { * name: { type: 'string' }, * age: { type: 'number' } * }, * required: ['name', 'age'] * }; * * const schema = s.fromJSONSchema(jsonSchema); * const user = schema.parse({ name: 'John', age: 30 }); * ``` */ export function fromJSONSchema(jsonSchema: JSONSchema): Schema { // Handle const (literal values) if (jsonSchema.const !== undefined) { const schema = literal(jsonSchema.const); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } // Handle anyOf (union or nullable) if (jsonSchema.anyOf && Array.isArray(jsonSchema.anyOf)) { // Check if it's a nullable pattern (anyOf with one schema and one null) if (jsonSchema.anyOf.length === 2) { const nullIndex = jsonSchema.anyOf.findIndex((s) => s.type === 'null'); if (nullIndex !== -1) { const otherIndex = nullIndex === 0 ? 1 : 0; const otherSchema = jsonSchema.anyOf[otherIndex]; if (otherSchema) { const innerSchema = fromJSONSchema(otherSchema); const schema = nullable(innerSchema); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } } } // Otherwise treat as union const schemas = jsonSchema.anyOf.map((s) => fromJSONSchema(s)); const schema = union(...schemas); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } // Handle oneOf (union) if (jsonSchema.oneOf && Array.isArray(jsonSchema.oneOf)) { const schemas = jsonSchema.oneOf.map((s) => fromJSONSchema(s)); const schema = union(...schemas); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } // Handle enum (union of literals) if (jsonSchema.enum && Array.isArray(jsonSchema.enum)) { const schemas = jsonSchema.enum.map((value) => literal(value as string | number | boolean)); const schema = union(...schemas); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } // Handle primitive types switch (jsonSchema.type) { case 'string': { const schema = string(); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } case 'number': case 'integer': { const schema = number(); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } case 'boolean': { const schema = boolean(); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } case 'null': { const schema = null_(); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } case 'array': { if (!jsonSchema.items) { throw new Error('Array type must have items property'); } const itemSchema = fromJSONSchema(jsonSchema.items); const schema = array(itemSchema); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } case 'object': { if (!jsonSchema.properties) { // Empty object schema const schema = object({}); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } const shape: Record> = {}; const requiredFields = new Set(jsonSchema.required || []); for (const [key, propSchema] of Object.entries(jsonSchema.properties)) { let fieldSchema = fromJSONSchema(propSchema); // If field is not in required array, make it optional if (!requiredFields.has(key)) { fieldSchema = optional(fieldSchema); } shape[key] = fieldSchema; } const schema = object(shape); if (jsonSchema.description) { schema.describe(jsonSchema.description); } return schema; } default: { // If no type is specified, try to infer from other properties if (jsonSchema.properties) { // Treat as object return fromJSONSchema({ ...jsonSchema, type: 'object' }); } if (jsonSchema.items) { // Treat as array return fromJSONSchema({ ...jsonSchema, type: 'array' }); } // Fallback to string schema for unrecognized/untyped JSON Schema // This provides a permissive default but may mask schema issues return string(); } } }