Skip to content

Commit c7a52b6

Browse files
authored
feat(schema): scaffold effect-to-zod bridge (#17273)
1 parent c4ccb50 commit c7a52b6

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Schema, SchemaAST } from "effect"
2+
import z from "zod"
3+
4+
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
5+
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
6+
}
7+
8+
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
9+
const out = body(ast)
10+
const desc = SchemaAST.resolveDescription(ast)
11+
const ref = SchemaAST.resolveIdentifier(ast)
12+
const next = desc ? out.describe(desc) : out
13+
return ref ? next.meta({ ref }) : next
14+
}
15+
16+
function body(ast: SchemaAST.AST): z.ZodTypeAny {
17+
if (SchemaAST.isOptional(ast)) return opt(ast)
18+
19+
switch (ast._tag) {
20+
case "String":
21+
return z.string()
22+
case "Number":
23+
return z.number()
24+
case "Boolean":
25+
return z.boolean()
26+
case "Null":
27+
return z.null()
28+
case "Undefined":
29+
return z.undefined()
30+
case "Any":
31+
case "Unknown":
32+
return z.unknown()
33+
case "Never":
34+
return z.never()
35+
case "Literal":
36+
return z.literal(ast.literal)
37+
case "Union":
38+
return union(ast)
39+
case "Objects":
40+
return object(ast)
41+
case "Arrays":
42+
return array(ast)
43+
case "Declaration":
44+
return decl(ast)
45+
default:
46+
return fail(ast)
47+
}
48+
}
49+
50+
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
51+
if (ast._tag !== "Union") return fail(ast)
52+
const items = ast.types.filter((item) => item._tag !== "Undefined")
53+
if (items.length === 1) return walk(items[0]).optional()
54+
if (items.length > 1)
55+
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
56+
return z.undefined().optional()
57+
}
58+
59+
function union(ast: SchemaAST.Union): z.ZodTypeAny {
60+
const items = ast.types.map(walk)
61+
if (items.length === 1) return items[0]
62+
if (items.length < 2) return fail(ast)
63+
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
64+
}
65+
66+
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
67+
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
68+
const sig = ast.indexSignatures[0]
69+
if (sig.parameter._tag !== "String") return fail(ast)
70+
return z.record(z.string(), walk(sig.type))
71+
}
72+
73+
if (ast.indexSignatures.length > 0) return fail(ast)
74+
75+
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
76+
}
77+
78+
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
79+
if (ast.elements.length > 0) return fail(ast)
80+
if (ast.rest.length !== 1) return fail(ast)
81+
return z.array(walk(ast.rest[0]))
82+
}
83+
84+
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
85+
if (ast.typeParameters.length !== 1) return fail(ast)
86+
return walk(ast.typeParameters[0])
87+
}
88+
89+
function fail(ast: SchemaAST.AST): never {
90+
const ref = SchemaAST.resolveIdentifier(ast)
91+
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
92+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Schema } from "effect"
3+
4+
import { zod } from "../../src/util/effect-zod"
5+
6+
describe("util.effect-zod", () => {
7+
test("converts class schemas for route dto shapes", () => {
8+
class Method extends Schema.Class<Method>("ProviderAuthMethod")({
9+
type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
10+
label: Schema.String,
11+
}) {}
12+
13+
const out = zod(Method)
14+
15+
expect(out.meta()?.ref).toBe("ProviderAuthMethod")
16+
expect(
17+
out.parse({
18+
type: "oauth",
19+
label: "OAuth",
20+
}),
21+
).toEqual({
22+
type: "oauth",
23+
label: "OAuth",
24+
})
25+
})
26+
27+
test("converts structs with optional fields, arrays, and records", () => {
28+
const out = zod(
29+
Schema.Struct({
30+
foo: Schema.optional(Schema.String),
31+
bar: Schema.Array(Schema.Number),
32+
baz: Schema.Record(Schema.String, Schema.Boolean),
33+
}),
34+
)
35+
36+
expect(
37+
out.parse({
38+
bar: [1, 2],
39+
baz: { ok: true },
40+
}),
41+
).toEqual({
42+
bar: [1, 2],
43+
baz: { ok: true },
44+
})
45+
expect(
46+
out.parse({
47+
foo: "hi",
48+
bar: [1],
49+
baz: { ok: false },
50+
}),
51+
).toEqual({
52+
foo: "hi",
53+
bar: [1],
54+
baz: { ok: false },
55+
})
56+
})
57+
58+
test("throws for unsupported tuple schemas", () => {
59+
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
60+
})
61+
})

0 commit comments

Comments
 (0)