import { Schema, SchemaAST } from "effect" import z from "zod" /** * Annotation key for providing a hand-crafted Zod schema that the walker * should use instead of re-deriving from the AST. Attach it via * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined if (override) return override const out = body(ast) const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) const next = desc ? out.describe(desc) : out return ref ? next.meta({ ref }) : next } function body(ast: SchemaAST.AST): z.ZodTypeAny { if (SchemaAST.isOptional(ast)) return opt(ast) switch (ast._tag) { case "String": return z.string() case "Number": return z.number() case "Boolean": return z.boolean() case "Null": return z.null() case "Undefined": return z.undefined() case "Any": case "Unknown": return z.unknown() case "Never": return z.never() case "Literal": return z.literal(ast.literal) case "Union": return union(ast) case "Objects": return object(ast) case "Arrays": return array(ast) case "Declaration": return decl(ast) default: return fail(ast) } } function opt(ast: SchemaAST.AST): z.ZodTypeAny { if (ast._tag !== "Union") return fail(ast) const items = ast.types.filter((item) => item._tag !== "Undefined") if (items.length === 1) return walk(items[0]).optional() if (items.length > 1) return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]).optional() return z.undefined().optional() } function union(ast: SchemaAST.Union): z.ZodTypeAny { // When every member is a string literal, emit z.enum() so that // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) } const items = ast.types.map(walk) if (items.length === 1) return items[0] if (items.length < 2) return fail(ast) const discriminator = (ast as any).annotations?.discriminator if (discriminator) { return z.discriminatedUnion(discriminator, items as [z.ZodObject, z.ZodObject, ...z.ZodObject[]]) } return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) } function object(ast: SchemaAST.Objects): z.ZodTypeAny { if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { const sig = ast.indexSignatures[0] if (sig.parameter._tag !== "String") return fail(ast) return z.record(z.string(), walk(sig.type)) } if (ast.indexSignatures.length > 0) return fail(ast) return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { if (ast.elements.length > 0) return fail(ast) if (ast.rest.length !== 1) return fail(ast) return z.array(walk(ast.rest[0])) } function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { if (ast.typeParameters.length !== 1) return fail(ast) return walk(ast.typeParameters[0]) } function fail(ast: SchemaAST.AST): never { const ref = SchemaAST.resolveIdentifier(ast) throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`) }