summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-13 10:59:57 -0400
committerGitHub <[email protected]>2026-03-13 10:59:57 -0400
commitc7a52b6a2d47c39fc2c3c6311355cd7b9286aac2 (patch)
treeed9fa96fcbd583d6620039e19b34abbc0bc6c091
parentc4ccb50c37933ee4a0d7a6e37c3a3be9c8322b59 (diff)
downloadopencode-c7a52b6a2d47c39fc2c3c6311355cd7b9286aac2.tar.gz
opencode-c7a52b6a2d47c39fc2c3c6311355cd7b9286aac2.zip
feat(schema): scaffold effect-to-zod bridge (#17273)
-rw-r--r--packages/opencode/src/util/effect-zod.ts92
-rw-r--r--packages/opencode/test/util/effect-zod.test.ts61
2 files changed, 153 insertions, 0 deletions
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
new file mode 100644
index 000000000..c1407594c
--- /dev/null
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -0,0 +1,92 @@
+import { Schema, SchemaAST } from "effect"
+import z from "zod"
+
+export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
+ return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
+}
+
+function walk(ast: SchemaAST.AST): z.ZodTypeAny {
+ 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<z.ZodTypeAny>]).optional()
+ return z.undefined().optional()
+}
+
+function union(ast: SchemaAST.Union): z.ZodTypeAny {
+ const items = ast.types.map(walk)
+ if (items.length === 1) return items[0]
+ if (items.length < 2) return fail(ast)
+ return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
+}
+
+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}`)
+}
diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts
new file mode 100644
index 000000000..4004ca2d2
--- /dev/null
+++ b/packages/opencode/test/util/effect-zod.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+
+import { zod } from "../../src/util/effect-zod"
+
+describe("util.effect-zod", () => {
+ test("converts class schemas for route dto shapes", () => {
+ class Method extends Schema.Class<Method>("ProviderAuthMethod")({
+ type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
+ label: Schema.String,
+ }) {}
+
+ const out = zod(Method)
+
+ expect(out.meta()?.ref).toBe("ProviderAuthMethod")
+ expect(
+ out.parse({
+ type: "oauth",
+ label: "OAuth",
+ }),
+ ).toEqual({
+ type: "oauth",
+ label: "OAuth",
+ })
+ })
+
+ test("converts structs with optional fields, arrays, and records", () => {
+ const out = zod(
+ Schema.Struct({
+ foo: Schema.optional(Schema.String),
+ bar: Schema.Array(Schema.Number),
+ baz: Schema.Record(Schema.String, Schema.Boolean),
+ }),
+ )
+
+ expect(
+ out.parse({
+ bar: [1, 2],
+ baz: { ok: true },
+ }),
+ ).toEqual({
+ bar: [1, 2],
+ baz: { ok: true },
+ })
+ expect(
+ out.parse({
+ foo: "hi",
+ bar: [1],
+ baz: { ok: false },
+ }),
+ ).toEqual({
+ foo: "hi",
+ bar: [1],
+ baz: { ok: false },
+ })
+ })
+
+ test("throws for unsupported tuple schemas", () => {
+ expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
+ })
+})