import { Effect, Option, 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") // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable // by reference across callers. const walkCache = new WeakMap() // Shared empty ParseOptions for the rare callers that need one — avoids // allocating a fresh object per parse inside refinements and transforms. const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } /** * Derive a Zod value from an Effect Schema (or a Schema-backed export with a * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, * `.omit`, `.extend`, and friends are accessible. * * The `zod()` walker returns `z.ZodType` because not every AST node decodes * to an object; this helper keeps the "I started from a `Schema.Struct`" cast * in one place instead of sprinkling `as unknown as z.ZodObject` across * call sites. * * The return is intentionally loose — carrying Schema field types through the * mapped `.omit()` / `.extend()` surface triggers brand-intersection * explosions for branded primitives (`string & Brand<"SessionID">` extends * `object` via the brand and gets walked into the prototype by `DeepPartial`, * `updateSchema`, etc.), and zod's inference through `z.ZodType` * wrappers also can't reconstruct `T` cleanly. Consumers that care about the * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. */ export function zodObject(schema: S): z.ZodObject { const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) return derived as unknown as z.ZodObject } function isZodType(value: unknown): value is z.ZodTypeAny { return typeof value === "object" && value !== null && "_zod" in value } /** * Emit a JSON Schema for a tool/route parameter schema — derives the zod form * via the walker so Effect Schema inputs flow through the same zod-openapi * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. */ export function toJsonSchema(schema: S) { return z.toJSONSchema(zod(schema), { io: "input" }) } function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached const result = walkUncached(ast) walkCache.set(ast, result) return result } function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined // `description` annotations layer on top of an override so callers can // reuse a shared override schema (e.g. `SessionID`) and still add a // per-field description on the outer wrapper. const base = override ?? bodyWithChecks(ast) const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) const described = desc ? base.describe(desc) : base return ref ? described.meta({ ref }) : described } function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.Class wraps its fields in a Declaration AST plus an encoding that // constructs the class instance. For the Zod derivation we want the plain // field shape (the decoded/consumer view), not the class instance — so // Declarations fall through to body(), not encoded(). User-level // Schema.decodeTo / Schema.transform attach encoding to non-Declaration // nodes, where we do apply the transform. // // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base } // Walk the encoded side and apply each link's decode to produce the decoded // shape. A node `Target` produced by `from.decodeTo(Target)` carries // `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls // nest the encoding via `Link.to` so walking it recursively threads all // prior transforms — typical encoding.length is 1. function encoded(ast: SchemaAST.AST): z.ZodTypeAny { const encoding = ast.encoding! return encoding.reduce( (acc, link) => acc.transform((v) => decode(link.transformation, v)), walk(encoding[0].to), ) } // Transformations built via pure `SchemaGetter.transform(fn)` (the common // decodeTo case) resolve synchronously, so running with no services is safe. // Effectful / middleware-based transforms will surface as Effect defects. function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { const exit = Effect.runSyncExit( (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< Option.Option >, ) if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) return Option.getOrElse(exit.value, () => value) } // Flatten FilterGroups and any nested variants into a linear list of Filters. // Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are // translated into native Zod methods so their JSON Schema output includes // the corresponding constraint (type: integer, exclusiveMinimum, pattern, …). // Anything else falls back to a single .superRefine layer — runtime-only, // emits no JSON Schema constraint. function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { const filters: SchemaAST.Filter[] = [] const collect = (c: SchemaAST.Check) => { if (c._tag === "FilterGroup") c.checks.forEach(collect) else filters.push(c) } checks.forEach(collect) const unhandled: SchemaAST.Filter[] = [] const translated = filters.reduce((acc, filter) => { const next = translateFilter(acc, filter) if (next) return next unhandled.push(filter) return acc }, out) if (unhandled.length === 0) return translated return translated.superRefine((value, ctx) => { for (const filter of unhandled) { const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) if (!issue) continue const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" ctx.addIssue({ code: "custom", message }) } }) } // Translate a well-known Effect Schema filter into a native Zod method call on // `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every // built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at // construction time. Returns `undefined` for unrecognised filters so the // caller can fall back to the generic .superRefine path. function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter): z.ZodTypeAny | undefined { const meta = (filter.annotations as { meta?: Record } | undefined)?.meta if (!meta || typeof meta._tag !== "string") return undefined switch (meta._tag) { case "isInt": return call(out, "int") case "isFinite": return call(out, "finite") case "isGreaterThan": return call(out, "gt", meta.exclusiveMinimum) case "isGreaterThanOrEqualTo": return call(out, "gte", meta.minimum) case "isLessThan": return call(out, "lt", meta.exclusiveMaximum) case "isLessThanOrEqualTo": return call(out, "lte", meta.maximum) case "isBetween": { const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum) if (!lo) return undefined return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum) } case "isMultipleOf": return call(out, "multipleOf", meta.divisor) case "isMinLength": return call(out, "min", meta.minLength) case "isMaxLength": return call(out, "max", meta.maxLength) case "isLengthBetween": { const lo = call(out, "min", meta.minimum) if (!lo) return undefined return call(lo, "max", meta.maximum) } case "isPattern": return call(out, "regex", meta.regExp) case "isStartsWith": return call(out, "startsWith", meta.startsWith) case "isEndsWith": return call(out, "endsWith", meta.endsWith) case "isIncludes": return call(out, "includes", meta.includes) case "isUUID": return call(out, "uuid") case "isULID": return call(out, "ulid") case "isBase64": return call(out, "base64") case "isBase64Url": return call(out, "base64url") } return undefined } // Invoke a named Zod method on `target` if it exists, otherwise return // undefined so the caller can fall back. Using this helper instead of a // typed cast keeps `translateFilter` free of per-case narrowing noise. function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined { const fn = (target as unknown as Record z.ZodTypeAny) | undefined>)[method] return typeof fn === "function" ? fn.apply(target, args) : undefined } function issueMessage(issue: any): string | undefined { if (typeof issue?.annotations?.message === "string") return issue.annotations.message if (typeof issue?.message === "string") return issue.message return undefined } 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") const inner = items.length === 1 ? walk(items[0]) : items.length > 1 ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) : z.undefined() // Schema.withDecodingDefault attaches an encoding `Link` whose transformation // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke // it to extract the default and emit `.default(...)` instead of `.optional()`. const fallback = extractDefault(ast) if (fallback !== undefined) return inner.default(fallback.value) return inner.optional() } type DecodeLink = { readonly transformation: { readonly decode: { readonly run: ( input: Option.Option, options: SchemaAST.ParseOptions, ) => Effect.Effect, unknown> } } } function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { const encoding = (ast as { encoding?: ReadonlyArray }).encoding if (!encoding?.length) return undefined // Walk the chain of encoding Links in order; the first Getter that produces // a value from Option.none wins. withDecodingDefault always puts its // defaulting Link adjacent to the optional Union. for (const link of encoding) { const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) if (probe._tag !== "Success") continue if (Option.isSome(probe.value)) return { value: probe.value.value } } return undefined } 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.annotations?.discriminator if (typeof discriminator === "string") { 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 { // Pure record: { [k: string]: V } 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)) } // Pure object with known fields and no index signatures. if (ast.indexSignatures.length === 0) { return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) } // Struct with a catchall (StructWithRest): known fields + index signature. // Only supports a single string-keyed index signature; multi-signature or // symbol/number keys fall through to fail. if (ast.indexSignatures.length !== 1) return fail(ast) const sig = ast.indexSignatures[0] if (sig.parameter._tag !== "String") return fail(ast) return z .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) .catchall(walk(sig.type)) } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { // Pure variadic arrays: { elements: [], rest: [item] } if (ast.elements.length === 0) { if (ast.rest.length !== 1) return fail(ast) return z.array(walk(ast.rest[0])) } // Fixed-length tuples: { elements: [a, b, ...], rest: [] } // Tuples with a variadic tail (...rest) are not yet supported. if (ast.rest.length > 0) return fail(ast) const items = ast.elements.map(walk) return z.tuple(items as [z.ZodTypeAny, ...Array]) } 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}`) }