Skip to content

Commit bb90f3b

Browse files
authored
feat(effect-zod): translate well-known filters into native Zod methods (#23209)
1 parent 05cdb7c commit bb90f3b

2 files changed

Lines changed: 275 additions & 5 deletions

File tree

packages/opencode/src/util/effect-zod.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,32 @@ function decode(transformation: SchemaAST.Link["transformation"], value: unknown
7575
return Option.getOrElse(exit.value, () => value)
7676
}
7777

78-
// Flatten FilterGroups and any nested variants into a linear list of Filters
79-
// so we can run all of them inside a single Zod .superRefine wrapper instead
80-
// of stacking N wrapper layers (one per check).
78+
// Flatten FilterGroups and any nested variants into a linear list of Filters.
79+
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
80+
// translated into native Zod methods so their JSON Schema output includes
81+
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
82+
// Anything else falls back to a single .superRefine layer — runtime-only,
83+
// emits no JSON Schema constraint.
8184
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
8285
const filters: SchemaAST.Filter<unknown>[] = []
8386
const collect = (c: SchemaAST.Check<unknown>) => {
8487
if (c._tag === "FilterGroup") c.checks.forEach(collect)
8588
else filters.push(c)
8689
}
8790
checks.forEach(collect)
88-
return out.superRefine((value, ctx) => {
89-
for (const filter of filters) {
91+
92+
const unhandled: SchemaAST.Filter<unknown>[] = []
93+
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
94+
const next = translateFilter(acc, filter)
95+
if (next) return next
96+
unhandled.push(filter)
97+
return acc
98+
}, out)
99+
100+
if (unhandled.length === 0) return translated
101+
102+
return translated.superRefine((value, ctx) => {
103+
for (const filter of unhandled) {
90104
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
91105
if (!issue) continue
92106
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
@@ -95,6 +109,71 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST
95109
})
96110
}
97111

112+
// Translate a well-known Effect Schema filter into a native Zod method call on
113+
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
114+
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
115+
// construction time. Returns `undefined` for unrecognised filters so the
116+
// caller can fall back to the generic .superRefine path.
117+
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
118+
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
119+
if (!meta || typeof meta._tag !== "string") return undefined
120+
switch (meta._tag) {
121+
case "isInt":
122+
return call(out, "int")
123+
case "isFinite":
124+
return call(out, "finite")
125+
case "isGreaterThan":
126+
return call(out, "gt", meta.exclusiveMinimum)
127+
case "isGreaterThanOrEqualTo":
128+
return call(out, "gte", meta.minimum)
129+
case "isLessThan":
130+
return call(out, "lt", meta.exclusiveMaximum)
131+
case "isLessThanOrEqualTo":
132+
return call(out, "lte", meta.maximum)
133+
case "isBetween": {
134+
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
135+
if (!lo) return undefined
136+
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
137+
}
138+
case "isMultipleOf":
139+
return call(out, "multipleOf", meta.divisor)
140+
case "isMinLength":
141+
return call(out, "min", meta.minLength)
142+
case "isMaxLength":
143+
return call(out, "max", meta.maxLength)
144+
case "isLengthBetween": {
145+
const lo = call(out, "min", meta.minimum)
146+
if (!lo) return undefined
147+
return call(lo, "max", meta.maximum)
148+
}
149+
case "isPattern":
150+
return call(out, "regex", meta.regExp)
151+
case "isStartsWith":
152+
return call(out, "startsWith", meta.startsWith)
153+
case "isEndsWith":
154+
return call(out, "endsWith", meta.endsWith)
155+
case "isIncludes":
156+
return call(out, "includes", meta.includes)
157+
case "isUUID":
158+
return call(out, "uuid")
159+
case "isULID":
160+
return call(out, "ulid")
161+
case "isBase64":
162+
return call(out, "base64")
163+
case "isBase64Url":
164+
return call(out, "base64url")
165+
}
166+
return undefined
167+
}
168+
169+
// Invoke a named Zod method on `target` if it exists, otherwise return
170+
// undefined so the caller can fall back. Using this helper instead of a
171+
// typed cast keeps `translateFilter` free of per-case narrowing noise.
172+
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
173+
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
174+
return typeof fn === "function" ? fn.apply(target, args) : undefined
175+
}
176+
98177
function issueMessage(issue: any): string | undefined {
99178
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
100179
if (typeof issue?.message === "string") return issue.message

packages/opencode/test/util/effect-zod.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,4 +478,195 @@ describe("util.effect-zod", () => {
478478
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
479479
})
480480
})
481+
482+
describe("well-known refinement translation", () => {
483+
test("Schema.isInt emits type: integer in JSON Schema", () => {
484+
const schema = zod(Schema.Number.check(Schema.isInt()))
485+
const native = json(z.number().int())
486+
expect(json(schema)).toEqual(native)
487+
expect(schema.parse(3)).toBe(3)
488+
expect(schema.safeParse(1.5).success).toBe(false)
489+
})
490+
491+
test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
492+
const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
493+
expect((json(schema) as any).exclusiveMinimum).toBe(0)
494+
expect(schema.parse(1)).toBe(1)
495+
expect(schema.safeParse(0).success).toBe(false)
496+
expect(schema.safeParse(-1).success).toBe(false)
497+
})
498+
499+
test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
500+
const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
501+
expect((json(schema) as any).minimum).toBe(0)
502+
expect(schema.parse(0)).toBe(0)
503+
expect(schema.safeParse(-1).success).toBe(false)
504+
})
505+
506+
test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
507+
const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
508+
expect((json(schema) as any).exclusiveMaximum).toBe(10)
509+
expect(schema.parse(9)).toBe(9)
510+
expect(schema.safeParse(10).success).toBe(false)
511+
})
512+
513+
test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
514+
const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
515+
expect((json(schema) as any).maximum).toBe(10)
516+
expect(schema.parse(10)).toBe(10)
517+
expect(schema.safeParse(11).success).toBe(false)
518+
})
519+
520+
test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
521+
const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
522+
expect((json(schema) as any).multipleOf).toBe(5)
523+
expect(schema.parse(10)).toBe(10)
524+
expect(schema.safeParse(7).success).toBe(false)
525+
})
526+
527+
test("Schema.isFinite validates at runtime", () => {
528+
const schema = zod(Schema.Number.check(Schema.isFinite()))
529+
expect(schema.parse(1)).toBe(1)
530+
expect(schema.safeParse(Infinity).success).toBe(false)
531+
expect(schema.safeParse(NaN).success).toBe(false)
532+
})
533+
534+
test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
535+
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
536+
const native = json(z.number().int().positive())
537+
expect(json(schema)).toEqual(native)
538+
expect(schema.parse(3)).toBe(3)
539+
expect(schema.safeParse(0).success).toBe(false)
540+
expect(schema.safeParse(1.5).success).toBe(false)
541+
})
542+
543+
test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
544+
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
545+
const native = json(z.number().int().min(0))
546+
expect(json(schema)).toEqual(native)
547+
expect(schema.parse(0)).toBe(0)
548+
expect(schema.safeParse(-1).success).toBe(false)
549+
})
550+
551+
test("Schema.isBetween emits both bounds", () => {
552+
const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
553+
const shape = json(schema) as any
554+
expect(shape.minimum).toBe(1)
555+
expect(shape.maximum).toBe(10)
556+
expect(schema.parse(5)).toBe(5)
557+
expect(schema.safeParse(11).success).toBe(false)
558+
expect(schema.safeParse(0).success).toBe(false)
559+
})
560+
561+
test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
562+
const schema = zod(
563+
Schema.Number.check(
564+
Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
565+
),
566+
)
567+
const shape = json(schema) as any
568+
expect(shape.exclusiveMinimum).toBe(1)
569+
expect(shape.exclusiveMaximum).toBe(10)
570+
expect(schema.parse(5)).toBe(5)
571+
expect(schema.safeParse(1).success).toBe(false)
572+
expect(schema.safeParse(10).success).toBe(false)
573+
})
574+
575+
test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
576+
const schema = zod(Schema.Number.check(Schema.isInt32()))
577+
const shape = json(schema) as any
578+
expect(shape.type).toBe("integer")
579+
expect(shape.minimum).toBe(-2147483648)
580+
expect(shape.maximum).toBe(2147483647)
581+
expect(schema.parse(42)).toBe(42)
582+
expect(schema.safeParse(1.5).success).toBe(false)
583+
expect(schema.safeParse(2147483648).success).toBe(false)
584+
})
585+
586+
test("Schema.isMinLength on string emits minLength", () => {
587+
const schema = zod(Schema.String.check(Schema.isMinLength(3)))
588+
expect((json(schema) as any).minLength).toBe(3)
589+
expect(schema.parse("abc")).toBe("abc")
590+
expect(schema.safeParse("ab").success).toBe(false)
591+
})
592+
593+
test("Schema.isMaxLength on string emits maxLength", () => {
594+
const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
595+
expect((json(schema) as any).maxLength).toBe(5)
596+
expect(schema.parse("abcde")).toBe("abcde")
597+
expect(schema.safeParse("abcdef").success).toBe(false)
598+
})
599+
600+
test("Schema.isLengthBetween on string emits both bounds", () => {
601+
const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
602+
const shape = json(schema) as any
603+
expect(shape.minLength).toBe(2)
604+
expect(shape.maxLength).toBe(4)
605+
expect(schema.parse("abc")).toBe("abc")
606+
expect(schema.safeParse("a").success).toBe(false)
607+
expect(schema.safeParse("abcde").success).toBe(false)
608+
})
609+
610+
test("Schema.isMinLength on array emits minItems", () => {
611+
const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
612+
expect((json(schema) as any).minItems).toBe(1)
613+
expect(schema.parse(["x"])).toEqual(["x"])
614+
expect(schema.safeParse([]).success).toBe(false)
615+
})
616+
617+
test("Schema.isPattern emits pattern", () => {
618+
const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
619+
expect((json(schema) as any).pattern).toBe("^per")
620+
expect(schema.parse("per_abc")).toBe("per_abc")
621+
expect(schema.safeParse("abc").success).toBe(false)
622+
})
623+
624+
test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
625+
const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
626+
const native = json(z.string().startsWith("per"))
627+
expect(json(schema)).toEqual(native)
628+
expect(schema.parse("per_abc")).toBe("per_abc")
629+
expect(schema.safeParse("abc").success).toBe(false)
630+
})
631+
632+
test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
633+
const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
634+
const native = json(z.string().endsWith(".json"))
635+
expect(json(schema)).toEqual(native)
636+
expect(schema.parse("a.json")).toBe("a.json")
637+
expect(schema.safeParse("a.txt").success).toBe(false)
638+
})
639+
640+
test("Schema.isUUID emits format: uuid", () => {
641+
const schema = zod(Schema.String.check(Schema.isUUID()))
642+
expect((json(schema) as any).format).toBe("uuid")
643+
})
644+
645+
test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
646+
// isInt is well-known (translates to .int()); the anonymous filter falls
647+
// back to superRefine.
648+
const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
649+
const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
650+
651+
const shape = json(schema) as any
652+
// Well-known translation is preserved — type is integer, not plain number
653+
expect(shape.type).toBe("integer")
654+
655+
// Runtime: both constraints fire
656+
expect(schema.parse(3)).toBe(3)
657+
expect(schema.safeParse(1.5).success).toBe(false)
658+
const seven = schema.safeParse(7)
659+
expect(seven.success).toBe(false)
660+
expect(seven.error!.issues[0].message).toBe("no sevens allowed")
661+
})
662+
663+
test("inside a struct field, well-known refinements propagate through", () => {
664+
// Mirrors config.ts port: z.number().int().positive().optional()
665+
const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
666+
const schema = zod(Schema.Struct({ port: Port }))
667+
const shape = json(schema) as any
668+
expect(shape.properties.port.type).toBe("integer")
669+
expect(shape.properties.port.exclusiveMinimum).toBe(0)
670+
})
671+
})
481672
})

0 commit comments

Comments
 (0)