Skip to content

Commit a106e9f

Browse files
authored
feat(zod): describe generated object schemas (#1430)
1 parent d487e0c commit a106e9f

7 files changed

Lines changed: 136 additions & 26 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ Appends `.strict()` to generated Zod object schemas.
373373

374374
type: `boolean` default: `false`
375375

376-
Appends `.describe()` to generated Zod fields from GraphQL descriptions.
376+
Appends `.describe()` to generated Zod fields and object schemas from GraphQL descriptions.
377377

378378
### `onlyEnums`
379379

src/schema_visitor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ export abstract class BaseSchemaVisitor implements SchemaVisitor {
6767
protected abstract buildInputFields(
6868
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
6969
visitor: Visitor,
70-
name: string
70+
name: string,
71+
description?: string
7172
): string;
7273

7374
protected buildTypeDefinitionArguments(

src/zod/index.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
schemaDepthVariable,
3131
unionLiterals,
3232
withDescription,
33+
withTypeDescription,
3334
} from '../zod_shared.js';
3435
import { buildZodOperationSchemas } from './operation.js';
3536

@@ -87,9 +88,9 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
8788
const name = visitor.convertName(node.name.value);
8889
this.importTypes.push(name);
8990
if (isOneOfInputObject(node))
90-
return this.buildOneOfInputFields(node.fields ?? [], visitor, name);
91+
return this.buildOneOfInputFields(node.fields ?? [], visitor, name, node.description?.value);
9192

92-
return this.buildInputFields(node.fields ?? [], visitor, name);
93+
return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value);
9394
},
9495
};
9596
}
@@ -116,7 +117,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
116117
.export()
117118
.asKind('const')
118119
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
119-
.withContent(buildObjectExpression(this.config, shape))
120+
.withContent(buildObjectExpression(this.config, shape, node.description?.value))
120121
.string + appendArguments
121122
);
122123

@@ -127,7 +128,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
127128
.export()
128129
.asKind('function')
129130
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
130-
.withBlock(buildObjectReturn(this.config, shape))
131+
.withBlock(buildObjectReturn(this.config, shape, node.description?.value))
131132
.string + appendArguments
132133
);
133134
}
@@ -157,7 +158,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
157158
.export()
158159
.asKind('const')
159160
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
160-
.withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n')))
161+
.withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value))
161162
.string + appendArguments
162163
);
163164

@@ -168,7 +169,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
168169
.export()
169170
.asKind('function')
170171
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
171-
.withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n')))
172+
.withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value))
172173
.string + appendArguments
173174
);
174175
}
@@ -253,10 +254,11 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
253254
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
254255
visitor: Visitor,
255256
name: string,
257+
description?: string,
256258
) {
257259
const typeName = visitor.prefixTypeNamespace(name);
258260
const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
259-
const objectSchema = buildObjectExpression(this.config, shape);
261+
const objectSchema = buildObjectExpression(this.config, shape, description);
260262

261263
switch (this.config.validationSchemaExportType) {
262264
case 'const':
@@ -273,7 +275,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
273275
.export()
274276
.asKind('function')
275277
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
276-
.withBlock(buildObjectReturn(this.config, shape))
278+
.withBlock(buildObjectReturn(this.config, shape, description))
277279
.string;
278280
}
279281
}
@@ -282,6 +284,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
282284
fields: readonly InputValueDefinitionNode[],
283285
visitor: Visitor,
284286
name: string,
287+
description?: string,
285288
) {
286289
const typeName = visitor.prefixTypeNamespace(name);
287290
const variants = fields.map((selectedField) => {
@@ -296,7 +299,11 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
296299

297300
return buildObjectExpression(this.config, shape);
298301
});
299-
const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0];
302+
const schema = withTypeDescription(
303+
this.config,
304+
description,
305+
variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0],
306+
);
300307

301308
switch (this.config.validationSchemaExportType) {
302309
case 'const':

src/zod_shared.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,29 @@ export function isOneOfInputObject(node: InputObjectTypeDefinitionNode): boolean
9191
return node.directives?.some(directive => directive.name.value === 'oneOf') === true;
9292
}
9393

94-
export function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined): string {
95-
return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n');
94+
export function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined, description?: string): string {
95+
return ['z.object({', shape, `})${strictObjectSuffix(config)}${descriptionSuffix(config, description)}`].join('\n');
9696
}
9797

98-
export function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined): string {
99-
return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}`)].join('\n');
98+
export function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined, description?: string): string {
99+
return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}${descriptionSuffix(config, description)}`)].join('\n');
100100
}
101101

102102
export function strictObjectSuffix(config: ValidationSchemaPluginConfig): string {
103103
return config.strictObjectSchemas === true ? '.strict()' : '';
104104
}
105105

106+
export function descriptionSuffix(config: ValidationSchemaPluginConfig, description: string | undefined): string {
107+
if (config.withDescriptions !== true || !description)
108+
return '';
109+
110+
return `.describe(${JSON.stringify(description)})`;
111+
}
112+
113+
export function withTypeDescription(config: ValidationSchemaPluginConfig, description: string | undefined, gen: string): string {
114+
return `${gen}${descriptionSuffix(config, description)}`;
115+
}
116+
106117
export function zodOptionalType(config: ValidationSchemaPluginConfig): string {
107118
return config.nullishBehavior ?? config.zodOptionalType ?? 'nullish';
108119
}

src/zodv4/index.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
schemaDepthVariable,
3333
unionLiterals,
3434
withDescription,
35+
withTypeDescription,
3536
} from '../zod_shared.js';
3637

3738
export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
@@ -87,9 +88,9 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
8788
const name = visitor.convertName(node.name.value);
8889
this.importTypes.push(name);
8990
if (isOneOfInputObject(node))
90-
return this.buildOneOfInputFields(node.fields ?? [], visitor, name);
91+
return this.buildOneOfInputFields(node.fields ?? [], visitor, name, node.description?.value);
9192

92-
return this.buildInputFields(node.fields ?? [], visitor, name);
93+
return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value);
9394
},
9495
};
9596
}
@@ -116,7 +117,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
116117
.export()
117118
.asKind('const')
118119
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
119-
.withContent(buildObjectExpression(this.config, shape))
120+
.withContent(buildObjectExpression(this.config, shape, node.description?.value))
120121
.string + appendArguments
121122
);
122123

@@ -127,7 +128,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
127128
.export()
128129
.asKind('function')
129130
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
130-
.withBlock(buildObjectReturn(this.config, shape))
131+
.withBlock(buildObjectReturn(this.config, shape, node.description?.value))
131132
.string + appendArguments
132133
);
133134
}
@@ -157,7 +158,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
157158
.export()
158159
.asKind('const')
159160
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
160-
.withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n')))
161+
.withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value))
161162
.string + appendArguments
162163
);
163164

@@ -168,7 +169,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
168169
.export()
169170
.asKind('function')
170171
.withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject<Properties<${typeName}>>`)
171-
.withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n')))
172+
.withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'), node.description?.value))
172173
.string + appendArguments
173174
);
174175
}
@@ -254,10 +255,11 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
254255
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
255256
visitor: Visitor,
256257
name: string,
258+
description?: string,
257259
) {
258260
const typeName = visitor.prefixTypeNamespace(name);
259261
const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
260-
const objectSchema = buildObjectExpression(this.config, shape);
262+
const objectSchema = buildObjectExpression(this.config, shape, description);
261263
const schemaType = hasDefaultValue(fields) ? `z.ZodType<${typeName}>` : `z.ZodObject<Properties<${typeName}>>`;
262264

263265
switch (this.config.validationSchemaExportType) {
@@ -275,7 +277,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
275277
.export()
276278
.asKind('function')
277279
.withName(`${name}Schema(): ${schemaType}`)
278-
.withBlock(buildObjectReturn(this.config, shape))
280+
.withBlock(buildObjectReturn(this.config, shape, description))
279281
.string;
280282
}
281283
}
@@ -284,6 +286,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
284286
fields: readonly InputValueDefinitionNode[],
285287
visitor: Visitor,
286288
name: string,
289+
description?: string,
287290
) {
288291
const typeName = visitor.prefixTypeNamespace(name);
289292
const variants = fields.map((selectedField) => {
@@ -298,7 +301,11 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
298301

299302
return buildObjectExpression(this.config, shape);
300303
});
301-
const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0];
304+
const schema = withTypeDescription(
305+
this.config,
306+
description,
307+
variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0],
308+
);
302309

303310
switch (this.config.validationSchemaExportType) {
304311
case 'const':

tests/zod.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2362,6 +2362,10 @@ describe('zod', () => {
23622362

23632363
it('supports strict objects and GraphQL descriptions', async () => {
23642364
const schema = buildSchema(/* GraphQL */ `
2365+
"""
2366+
User "input"
2367+
with \\ slash
2368+
"""
23652369
input UserInput {
23662370
"Display name shown to users"
23672371
name: String!
@@ -2376,7 +2380,46 @@ describe('zod', () => {
23762380
);
23772381

23782382
expect(result.content).toContain('name: z.string().describe("Display name shown to users")');
2379-
expect(result.content).toContain('}).strict()');
2383+
expect(result.content).toContain('}).strict().describe("User \\"input\\"\\nwith \\\\ slash")');
2384+
});
2385+
2386+
it('adds type-level descriptions to const object schemas', async () => {
2387+
const schema = buildSchema(/* GraphQL */ `
2388+
"Node contract"
2389+
interface Node {
2390+
id: ID!
2391+
}
2392+
2393+
"User object"
2394+
type User implements Node {
2395+
id: ID!
2396+
name: String!
2397+
}
2398+
2399+
"User input"
2400+
input UserInput {
2401+
name: String!
2402+
}
2403+
`);
2404+
2405+
const result = await plugin(
2406+
schema,
2407+
[],
2408+
{
2409+
schema: 'zod',
2410+
validationSchemaExportType: 'const',
2411+
withDescriptions: true,
2412+
withObjectType: true,
2413+
},
2414+
{},
2415+
);
2416+
2417+
expect(result.content).toContain('export const NodeSchema: z.ZodObject<Properties<Node>>');
2418+
expect(result.content).toContain('}).describe("Node contract")');
2419+
expect(result.content).toContain('export const UserSchema: z.ZodObject<Properties<User>>');
2420+
expect(result.content).toContain('}).describe("User object")');
2421+
expect(result.content).toContain('export const UserInputSchema: z.ZodObject<Properties<UserInput>>');
2422+
expect(result.content).toContain('}).describe("User input")');
23802423
});
23812424

23822425
it('respects enumPrefix: false when typesPrefix is configured', async () => {

tests/zodv4.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2242,19 +2242,60 @@ describe('zodv4', () => {
22422242
placeholder: String!
22432243
}
22442244
2245+
"Exactly one event payload"
22452246
input EventInput @oneOf {
22462247
assignEvent: AssignEventInput
22472248
placeholder: PlaceholderEventInput
22482249
}
22492250
`);
22502251

2251-
const result = await plugin(schema, [], { schema: 'zodv4' }, {});
2252+
const result = await plugin(schema, [], { schema: 'zodv4', withDescriptions: true }, {});
22522253

22532254
expect(result.content).toContain('export function EventInputSchema(): z.ZodType<EventInput>');
22542255
expect(result.content).toContain('assignEvent: z.lazy(() => AssignEventInputSchema())');
22552256
expect(result.content).toContain('placeholder: z.never().optional()');
22562257
expect(result.content).toContain('placeholder: z.lazy(() => PlaceholderEventInputSchema())');
22572258
expect(result.content).toContain('assignEvent: z.never().optional()');
2259+
expect(result.content).toContain(']).describe("Exactly one event payload")');
2260+
});
2261+
2262+
it('adds type-level descriptions to const object schemas', async () => {
2263+
const schema = buildSchema(/* GraphQL */ `
2264+
"Node contract"
2265+
interface Node {
2266+
id: ID!
2267+
}
2268+
2269+
"User object"
2270+
type User implements Node {
2271+
id: ID!
2272+
name: String!
2273+
}
2274+
2275+
"User input"
2276+
input UserInput {
2277+
name: String!
2278+
}
2279+
`);
2280+
2281+
const result = await plugin(
2282+
schema,
2283+
[],
2284+
{
2285+
schema: 'zodv4',
2286+
validationSchemaExportType: 'const',
2287+
withDescriptions: true,
2288+
withObjectType: true,
2289+
},
2290+
{},
2291+
);
2292+
2293+
expect(result.content).toContain('export const NodeSchema: z.ZodObject<Properties<Node>>');
2294+
expect(result.content).toContain('}).describe("Node contract")');
2295+
expect(result.content).toContain('export const UserSchema: z.ZodObject<Properties<User>>');
2296+
expect(result.content).toContain('}).describe("User object")');
2297+
expect(result.content).toContain('export const UserInputSchema: z.ZodObject<Properties<UserInput>>');
2298+
expect(result.content).toContain('}).describe("User input")');
22582299
});
22592300

22602301
it('supports configurable nullable field behavior', async () => {

0 commit comments

Comments
 (0)