@dxos/effect-zod 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Verifies the Effect Schema → Zod converter produces zod schemas equivalent
6
+ // to the ones we'd write by hand. The MCP SDK doesn't care HOW the zod schema
7
+ // was built, only that it parses correctly — so each test asserts
8
+ // parse-equivalence with a hand-written zod schema for the same shape.
9
+
10
+ import * as Schema from 'effect/Schema';
11
+ import { describe, test } from 'vitest';
12
+ import { z } from 'zod';
13
+
14
+ import { effectFieldsToZod } from './effect-to-zod';
15
+
16
+ describe('effectFieldsToZod', () => {
17
+ test('Schema.String required → z.string()', ({ expect }) => {
18
+ const out = effectFieldsToZod(
19
+ Schema.Struct({
20
+ name: Schema.String.annotations({ description: 'a name' }),
21
+ }),
22
+ );
23
+ expect(out.name.parse('hello')).toBe('hello');
24
+ expect(() => out.name.parse(undefined)).toThrow();
25
+ expect(out.name.description).toBe('a name');
26
+ });
27
+
28
+ test('Schema.optional(Schema.String) → z.string().optional()', ({ expect }) => {
29
+ const out = effectFieldsToZod(
30
+ Schema.Struct({
31
+ nick: Schema.optional(Schema.String).annotations({ description: 'nick' }),
32
+ }),
33
+ );
34
+ expect(out.nick.parse('hi')).toBe('hi');
35
+ expect(out.nick.parse(undefined)).toBe(undefined);
36
+ expect(out.nick.description).toBe('nick');
37
+ });
38
+
39
+ test('Schema.Boolean → z.boolean()', ({ expect }) => {
40
+ const out = effectFieldsToZod(Schema.Struct({ enabled: Schema.Boolean }));
41
+ expect(out.enabled.parse(true)).toBe(true);
42
+ expect(() => out.enabled.parse('true')).toThrow();
43
+ });
44
+
45
+ test('Schema.Number with int/positive/lessThanOrEqualTo refinements → z.number().int().positive().max(N)', ({
46
+ expect,
47
+ }) => {
48
+ const out = effectFieldsToZod(
49
+ Schema.Struct({
50
+ limit: Schema.optional(
51
+ Schema.Number.pipe(Schema.int(), Schema.positive(), Schema.lessThanOrEqualTo(200)),
52
+ ).annotations({ description: 'limit' }),
53
+ }),
54
+ );
55
+ expect(out.limit.parse(50)).toBe(50);
56
+ expect(out.limit.parse(undefined)).toBe(undefined);
57
+ expect(() => out.limit.parse(0)).toThrow(); // not positive
58
+ expect(() => out.limit.parse(1.5)).toThrow(); // not int
59
+ expect(() => out.limit.parse(201)).toThrow(); // exceeds max
60
+ expect(() => out.limit.parse(-1)).toThrow(); // not positive
61
+ });
62
+
63
+ test('Schema.Literal(...) string union → z.enum([...])', ({ expect }) => {
64
+ const out = effectFieldsToZod(
65
+ Schema.Struct({
66
+ kind: Schema.Literal('function', 'class', 'interface'),
67
+ }),
68
+ );
69
+ expect(out.kind.parse('function')).toBe('function');
70
+ expect(() => out.kind.parse('module')).toThrow();
71
+ });
72
+
73
+ test('Schema.Array(Schema.String) → z.array(z.string())', ({ expect }) => {
74
+ const out = effectFieldsToZod(
75
+ Schema.Struct({
76
+ tags: Schema.Array(Schema.String),
77
+ }),
78
+ );
79
+ expect(out.tags.parse(['a', 'b'])).toEqual(['a', 'b']);
80
+ expect(() => out.tags.parse([1, 2])).toThrow();
81
+ });
82
+
83
+ test('Schema.Array(Schema.Literal(...)) → z.array(z.enum([...]))', ({ expect }) => {
84
+ const out = effectFieldsToZod(
85
+ Schema.Struct({
86
+ include: Schema.optional(Schema.Array(Schema.Literal('source', 'jsdoc'))),
87
+ }),
88
+ );
89
+ expect(out.include.parse(['source'])).toEqual(['source']);
90
+ expect(out.include.parse(undefined)).toBe(undefined);
91
+ expect(() => out.include.parse(['random'])).toThrow();
92
+ });
93
+
94
+ test('description on optional wrapper survives the conversion', ({ expect }) => {
95
+ // The user-facing pattern: `.annotations({ description })` is added at the
96
+ // wrapper level (on `Schema.optional(...)`). The converter must read it
97
+ // from the PropertySignatureDeclaration's annotations.
98
+ const out = effectFieldsToZod(
99
+ Schema.Struct({
100
+ x: Schema.optional(Schema.String).annotations({ description: 'X-axis' }),
101
+ }),
102
+ );
103
+ expect(out.x.description).toBe('X-axis');
104
+ });
105
+
106
+ test('multiple fields convert independently', ({ expect }) => {
107
+ const out = effectFieldsToZod(
108
+ Schema.Struct({
109
+ name: Schema.optional(Schema.String).annotations({ description: 'pkg name' }),
110
+ privateOnly: Schema.optional(Schema.Boolean).annotations({ description: 'private?' }),
111
+ limit: Schema.optional(
112
+ Schema.Number.pipe(Schema.int(), Schema.positive(), Schema.lessThanOrEqualTo(200)),
113
+ ).annotations({ description: 'cap' }),
114
+ }),
115
+ );
116
+ // Spread into z.object to validate as a unit (which is how the MCP SDK
117
+ // consumes this record).
118
+ const combined = z.object(out);
119
+ expect(combined.parse({ name: 'foo', privateOnly: true, limit: 10 })).toEqual({
120
+ name: 'foo',
121
+ privateOnly: true,
122
+ limit: 10,
123
+ });
124
+ expect(combined.parse({})).toEqual({});
125
+ });
126
+
127
+ test('unsupported AST nodes throw with a clear message', ({ expect }) => {
128
+ // Schema.Class, Schema.Tuple (fixed elements), Date, transformations etc.
129
+ // aren't currently used in our tool inputs and aren't supported. Strict
130
+ // denylist beats silent miscompilation.
131
+ expect(() =>
132
+ effectFieldsToZod(
133
+ Schema.Struct({
134
+ // Schema.Date is built on a transformation we don't unwrap.
135
+ when: Schema.Date,
136
+ }),
137
+ ),
138
+ ).toThrow(/failed to convert field "when"/);
139
+ });
140
+ });
@@ -0,0 +1,248 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Effect Schema → Zod converter, scoped to the patterns we use in MCP tool
6
+ // inputs. The MCP SDK requires zod schemas for `inputSchema`, but we want to
7
+ // author tool inputs in Effect Schema so:
8
+ //
9
+ // 1. The same definitions can be consumed by `react-ui-form` (which renders
10
+ // forms from Effect Schema directly).
11
+ // 2. We get Effect's annotation system (Description, Title, JSONSchema)
12
+ // everywhere, plus refinements that compose cleanly with the rest of
13
+ // the codebase.
14
+ //
15
+ // What's supported (anything outside this list throws at startup with a clear
16
+ // message — strict denylist beats silent miscompilation):
17
+ //
18
+ // Schema.String → z.string()
19
+ // Schema.Number → z.number()
20
+ // Schema.Boolean → z.boolean()
21
+ // Schema.Literal('a','b') → z.enum(['a','b'])
22
+ // Schema.Array(x) → z.array(zodOf(x))
23
+ // Schema.optional(x) → .optional()
24
+ // Schema.int() → .int() (via JSONSchema annotation)
25
+ // Schema.positive() → .positive() (via JSONSchema annotation)
26
+ // Schema.lessThanOrEqualTo(n) → .max(n) (via JSONSchema annotation)
27
+ // description annotation → .describe(...)
28
+ //
29
+ // We read refinements off the JSONSchema annotation Effect attaches to its
30
+ // stdlib refinements, NOT off SchemaId symbols — that gives us a stable
31
+ // integration point that doesn't break across Effect minor versions.
32
+
33
+ import * as Schema from 'effect/Schema';
34
+ import { z } from 'zod';
35
+
36
+ const DescriptionAnnotationId = Symbol.for('effect/annotation/Description');
37
+ const JSONSchemaAnnotationId = Symbol.for('effect/annotation/JSONSchema');
38
+
39
+ /**
40
+ * Convert the fields of an Effect `Schema.Struct(...)` into the
41
+ * `Record<string, z.ZodTypeAny>` shape the MCP SDK's `registerTool` expects
42
+ * for `inputSchema`.
43
+ */
44
+ export const effectFieldsToZod = <Fields extends Schema.Struct.Fields>(
45
+ schema: Schema.Struct<Fields>,
46
+ ): Record<keyof Fields & string, z.ZodTypeAny> => {
47
+ const out: Record<string, z.ZodTypeAny> = {};
48
+ for (const [name, prop] of Object.entries(schema.fields)) {
49
+ try {
50
+ out[name] = propToZod((prop as { ast: AnyAst }).ast);
51
+ } catch (err) {
52
+ throw new Error(`effectFieldsToZod: failed to convert field "${name}": ${(err as Error).message}`);
53
+ }
54
+ }
55
+ return out as Record<keyof Fields & string, z.ZodTypeAny>;
56
+ };
57
+
58
+ /**
59
+ * `Schema.PropertySignature` (the AST node a struct's `.fields[name].ast` is)
60
+ * isn't part of the main `SchemaAST.AST` union — it's a separate hierarchy.
61
+ * We use a structural type here that matches both shapes we encounter:
62
+ *
63
+ * - struct field signatures: `{ _tag: 'PropertySignatureDeclaration', type, isOptional, ... }`
64
+ * - bare types (required fields, walked recursively): everything in `SchemaAST.AST`
65
+ *
66
+ * Treating `_tag` as a free string and downcasting selectively lets us handle
67
+ * both without reaching for `as unknown as never` workarounds.
68
+ */
69
+ type AnyAst = { _tag: string; annotations?: Record<symbol, unknown> } & Record<string, unknown>;
70
+
71
+ /**
72
+ * Convert one struct field's AST. Property signatures wrap the actual schema
73
+ * AST with optional/readonly metadata; required fields are the AST directly.
74
+ */
75
+ const propToZod = (ast: AnyAst): z.ZodTypeAny => {
76
+ if (ast._tag === 'PropertySignatureDeclaration') {
77
+ // `Schema.optional(X)` produces a PropertySignatureDeclaration whose `type`
78
+ // is `Union(X, UndefinedKeyword)`. Peel UndefinedKeyword before recursing
79
+ // so the converter operates on the user-facing type, then mark optional.
80
+ const description = readDescription(ast);
81
+ const isOptional = Boolean(ast.isOptional);
82
+ const innerAst = unwrapOptionalUnion(ast.type as AnyAst, isOptional);
83
+ let zod = astToZod(innerAst);
84
+ if (isOptional) {
85
+ zod = zod.optional();
86
+ }
87
+ if (description !== undefined) {
88
+ zod = zod.describe(description);
89
+ }
90
+ return zod;
91
+ }
92
+ return astToZod(ast);
93
+ };
94
+
95
+ /**
96
+ * Peel `UndefinedKeyword` from an optional field's union. Effect models
97
+ * `optional(X)` as `Union(X, UndefinedKeyword)`; if that's what we have AND
98
+ * the prop is optional, return X. Otherwise pass through unchanged.
99
+ */
100
+ const unwrapOptionalUnion = (ast: AnyAst, isOptional: boolean): AnyAst => {
101
+ if (!isOptional || ast._tag !== 'Union') {
102
+ return ast;
103
+ }
104
+ const types = (ast.types as AnyAst[]).filter((t) => t._tag !== 'UndefinedKeyword');
105
+ if (types.length === 1) {
106
+ return types[0];
107
+ }
108
+ // Multi-branch union after stripping undefined — preserve as a Union; the
109
+ // top-level switch handles literal-only unions (enums). Anything else
110
+ // throws with a clear message.
111
+ return { ...ast, types } as AnyAst;
112
+ };
113
+
114
+ const astToZod = (ast: AnyAst): z.ZodTypeAny => {
115
+ let zod: z.ZodTypeAny;
116
+ switch (ast._tag) {
117
+ case 'StringKeyword':
118
+ zod = z.string();
119
+ break;
120
+ case 'NumberKeyword':
121
+ zod = z.number();
122
+ break;
123
+ case 'BooleanKeyword':
124
+ zod = z.boolean();
125
+ break;
126
+ case 'Literal':
127
+ // `z.literal` accepts string | number | boolean | null. Effect's literal
128
+ // value is already constrained to those by Schema.Literal's signature.
129
+ zod = z.literal(ast.literal as string | number | boolean | null);
130
+ break;
131
+ case 'Union': {
132
+ // Only support unions where every branch is a string literal — that's
133
+ // what `Schema.Literal('a', 'b', 'c')` produces, and it maps directly
134
+ // to `z.enum`. Other unions (mixed types, refinements) aren't currently
135
+ // used in our tool inputs and would need a richer conversion.
136
+ const types = ast.types as AnyAst[];
137
+ const allStringLiteral = types.every((t) => t._tag === 'Literal' && typeof t.literal === 'string');
138
+ if (!allStringLiteral) {
139
+ throw new Error(
140
+ `unsupported Union — only enum-of-string-literals supported, got branches: ${types.map((t) => t._tag).join(', ')}`,
141
+ );
142
+ }
143
+ const values = types.map((t) => t.literal as string) as [string, ...string[]];
144
+ zod = z.enum(values);
145
+ break;
146
+ }
147
+ case 'TupleType': {
148
+ // `Schema.Array(X)` produces a TupleType with a single rest element of
149
+ // type X. Fixed tuples (`Schema.Tuple(...)`) aren't currently used.
150
+ const tuple = ast as { rest?: ReadonlyArray<{ type: AnyAst }>; elements?: ReadonlyArray<unknown> };
151
+ if (tuple.elements && tuple.elements.length > 0) {
152
+ throw new Error('fixed-length tuples are not supported — use Schema.Array(X)');
153
+ }
154
+ const elem = tuple.rest?.[0]?.type;
155
+ if (!elem) {
156
+ throw new Error('TupleType without rest element — Schema.Array(X) is the only supported array form');
157
+ }
158
+ zod = z.array(astToZod(elem));
159
+ break;
160
+ }
161
+ case 'Refinement': {
162
+ // Walk the refinement chain down to the base type, collecting JSONSchema
163
+ // annotations along the way. Apply each refinement's Zod equivalent on
164
+ // top of the base. Order is deterministic: int → positive → max, so we
165
+ // never trigger Zod's "method must come after .int()" sequencing rule.
166
+ const { base, jsonSchemas } = collectRefinements(ast);
167
+ let z0 = astToZod(base);
168
+ for (const js of jsonSchemas) {
169
+ z0 = applyJsonSchemaRefinement(z0, js);
170
+ }
171
+ zod = z0;
172
+ break;
173
+ }
174
+ default:
175
+ throw new Error(`unsupported Effect Schema AST node: ${ast._tag}`);
176
+ }
177
+
178
+ // Pass through Description annotation. This includes Effect's stdlib
179
+ // defaults ("a string", "a positive number") if the user didn't override —
180
+ // tool authors should always supply their own description for LLM trigger
181
+ // accuracy, but we don't enforce that here.
182
+ const description = readDescription(ast);
183
+ if (description !== undefined) {
184
+ zod = zod.describe(description);
185
+ }
186
+ return zod;
187
+ };
188
+
189
+ const collectRefinements = (ast: AnyAst): { base: AnyAst; jsonSchemas: Array<Record<string, unknown>> } => {
190
+ const jsonSchemas: Array<Record<string, unknown>> = [];
191
+ let cursor: AnyAst = ast;
192
+ while (cursor._tag === 'Refinement') {
193
+ const js = cursor.annotations?.[JSONSchemaAnnotationId];
194
+ if (typeof js === 'object' && js !== null) {
195
+ jsonSchemas.push(js as Record<string, unknown>);
196
+ } else {
197
+ throw new Error(
198
+ 'Refinement is missing a JSONSchema annotation — only Effect stdlib refinements (int, positive, lessThanOrEqualTo, etc.) are currently supported',
199
+ );
200
+ }
201
+ cursor = cursor.from as AnyAst;
202
+ }
203
+ // Innermost refinements were pushed first; reverse so outer refinements
204
+ // (e.g. `lessThanOrEqualTo`) apply LAST, after `.int().positive()` etc.
205
+ jsonSchemas.reverse();
206
+ return { base: cursor, jsonSchemas };
207
+ };
208
+
209
+ /**
210
+ * Map a JSON Schema fragment from one Effect refinement to a Zod method on a
211
+ * `z.number()`. We only translate the shapes Effect emits for its stdlib
212
+ * refinements — anything else throws so the converter doesn't silently
213
+ * generate an under-constrained Zod schema.
214
+ */
215
+ const applyJsonSchemaRefinement = (zod: z.ZodTypeAny, js: Record<string, unknown>): z.ZodTypeAny => {
216
+ // Only z.number() supports the methods we need. Defensive cast.
217
+ let z0 = zod as z.ZodNumber;
218
+ let touched = false;
219
+ if (js.type === 'integer') {
220
+ z0 = z0.int();
221
+ touched = true;
222
+ }
223
+ if (typeof js.exclusiveMinimum === 'number' && js.exclusiveMinimum === 0) {
224
+ z0 = z0.positive();
225
+ touched = true;
226
+ }
227
+ if (typeof js.maximum === 'number') {
228
+ z0 = z0.max(js.maximum);
229
+ touched = true;
230
+ }
231
+ if (typeof js.minimum === 'number') {
232
+ z0 = z0.min(js.minimum);
233
+ touched = true;
234
+ }
235
+ if (!touched) {
236
+ throw new Error(`unsupported JSONSchema refinement fragment: ${JSON.stringify(js)}`);
237
+ }
238
+ return z0;
239
+ };
240
+
241
+ const readDescription = (ast: AnyAst): string | undefined => {
242
+ const annotations = ast.annotations;
243
+ if (!annotations) {
244
+ return undefined;
245
+ }
246
+ const value = annotations[DescriptionAnnotationId];
247
+ return typeof value === 'string' ? value : undefined;
248
+ };
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { effectFieldsToZod } from './effect-to-zod';