Skip to content

Commit 83e3371

Browse files
committed
feat: support comment propagation from GraphQL to Zod
1 parent 02c516c commit 83e3371

8 files changed

Lines changed: 323 additions & 17 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,47 @@ Uses the full path of the enum type as the default value instead of the stringif
303303

304304
Related: https://the-guild.dev/graphql/codegen/docs/config-reference/naming-convention#namingconvention
305305

306+
### `withDescriptions`
307+
308+
type: `boolean` default: `false`
309+
310+
Generates `.describe()` calls on Zod schemas using GraphQL descriptions. When enabled, any GraphQL type or field that has a description comment will have `.describe('...')` appended to its generated Zod schema.
311+
312+
Only applies when `schema` is set to `zod` or `zodv4`.
313+
314+
```yml
315+
generates:
316+
path/to/graphql.ts:
317+
plugins:
318+
- typescript
319+
- typescript-validation-schema
320+
config:
321+
schema: zod
322+
withDescriptions: true
323+
```
324+
325+
For the following GraphQL schema:
326+
327+
```graphql
328+
"""A user input"""
329+
input UserInput {
330+
"""The user's name"""
331+
name: String!
332+
email: String
333+
}
334+
```
335+
336+
It generates:
337+
338+
```ts
339+
export function UserInputSchema(): z.ZodObject<Properties<UserInput>> {
340+
return z.object({
341+
name: z.string().describe('The user\'s name'),
342+
email: z.string().nullish()
343+
}).describe('A user input')
344+
}
345+
```
346+
306347
### `directives`
307348

308349
type: `DirectiveConfig`

src/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,25 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig {
305305
* ```
306306
*/
307307
namingConvention?: NamingConventionMap
308+
/**
309+
* @description Generates `.describe()` calls on Zod schemas using GraphQL descriptions.
310+
* When enabled, any GraphQL type or field that has a description comment will have
311+
* `.describe('...')` appended to its generated Zod schema.
312+
* Only applies when schema is set to 'zod' or 'zodv4'.
313+
* @default false
314+
*
315+
* @exampleMarkdown
316+
* ```yml
317+
* generates:
318+
* path/to/file.ts:
319+
* plugins:
320+
* - graphql-codegen-validation-schema
321+
* config:
322+
* schema: zod
323+
* withDescriptions: true
324+
* ```
325+
*/
326+
withDescriptions?: boolean
308327
/**
309328
* @description Generates validation schema with more API based on directive schema.
310329
* @exampleMarkdown

src/graphql.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,7 @@ export function escapeGraphQLCharacters(input: string): string {
219219
// eslint-disable-next-line regexp/no-escape-backspace
220220
return input.replace(/["\\/\f\n\r\t\b]/g, match => escapeMap[match]);
221221
}
222+
223+
export function escapeForDescribe(value: string): string {
224+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
225+
}

src/schema_visitor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export abstract class BaseSchemaVisitor implements SchemaVisitor {
4646
protected abstract buildInputFields(
4747
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
4848
visitor: Visitor,
49-
name: string
49+
name: string,
50+
description?: string
5051
): string;
5152

5253
protected buildTypeDefinitionArguments(

src/zod/index.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { buildApi, formatDirectiveConfig } from '../directive.js';
2424
import {
2525
escapeGraphQLCharacters,
26+
escapeForDescribe,
2627
InterfaceTypeDefinitionBuilder,
2728
isListType,
2829
isNamedType,
@@ -79,7 +80,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
7980
const visitor = this.createVisitor('input');
8081
const name = visitor.convertName(node.name.value);
8182
this.importTypes.push(name);
82-
return this.buildInputFields(node.fields ?? [], visitor, name);
83+
return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value);
8384
},
8485
};
8586
}
@@ -99,14 +100,18 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
99100
// Building schema for fields.
100101
const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
101102

103+
const maybeDescribe = this.config.withDescriptions && node.description?.value
104+
? `.describe('${escapeForDescribe(node.description.value)}')`
105+
: '';
106+
102107
switch (this.config.validationSchemaExportType) {
103108
case 'const':
104109
return (
105110
new DeclarationBlock({})
106111
.export()
107112
.asKind('const')
108113
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
109-
.withContent([`z.object({`, shape, '})'].join('\n'))
114+
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
110115
.string + appendArguments
111116
);
112117

@@ -117,7 +122,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
117122
.export()
118123
.asKind('function')
119124
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
120-
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
125+
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
121126
.string + appendArguments
122127
);
123128
}
@@ -140,6 +145,10 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
140145
// Building schema for fields.
141146
const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
142147

148+
const maybeDescribe = this.config.withDescriptions && node.description?.value
149+
? `.describe('${escapeForDescribe(node.description.value)}')`
150+
: '';
151+
143152
switch (this.config.validationSchemaExportType) {
144153
case 'const':
145154
return (
@@ -152,7 +161,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
152161
`z.object({`,
153162
indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
154163
shape,
155-
'})',
164+
`})${maybeDescribe}`,
156165
].join('\n'),
157166
)
158167
.string + appendArguments
@@ -170,7 +179,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
170179
indent(`return z.object({`),
171180
indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
172181
shape,
173-
indent('})'),
182+
indent(`})${maybeDescribe}`),
174183
].join('\n'),
175184
)
176185
.string + appendArguments
@@ -253,17 +262,22 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
253262
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
254263
visitor: Visitor,
255264
name: string,
265+
description?: string,
256266
) {
257267
const typeName = visitor.prefixTypeNamespace(name);
258268
const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
259269

270+
const maybeDescribe = this.config.withDescriptions && description
271+
? `.describe('${escapeForDescribe(description)}')`
272+
: '';
273+
260274
switch (this.config.validationSchemaExportType) {
261275
case 'const':
262276
return new DeclarationBlock({})
263277
.export()
264278
.asKind('const')
265279
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
266-
.withContent(['z.object({', shape, '})'].join('\n'))
280+
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
267281
.string;
268282

269283
case 'function':
@@ -272,15 +286,19 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor {
272286
.export()
273287
.asKind('function')
274288
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
275-
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
289+
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
276290
.string;
277291
}
278292
}
279293
}
280294

281295
function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string {
282296
const gen = generateFieldTypeZodSchema(config, visitor, field, field.type);
283-
return indent(`${field.name.value}: ${maybeLazy(visitor, field.type, gen)}`, indentCount);
297+
let schema = maybeLazy(visitor, field.type, gen);
298+
if (config.withDescriptions && field.description?.value) {
299+
schema = `${schema}.describe('${escapeForDescribe(field.description.value)}')`;
300+
}
301+
return indent(`${field.name.value}: ${schema}`, indentCount);
284302
}
285303

286304
function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string {

src/zodv4/index.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { buildApi, formatDirectiveConfig } from '../directive.js';
2424
import {
2525
escapeGraphQLCharacters,
26+
escapeForDescribe,
2627
InterfaceTypeDefinitionBuilder,
2728
isListType,
2829
isNamedType,
@@ -78,7 +79,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
7879
const visitor = this.createVisitor('input');
7980
const name = visitor.convertName(node.name.value);
8081
this.importTypes.push(name);
81-
return this.buildInputFields(node.fields ?? [], visitor, name);
82+
return this.buildInputFields(node.fields ?? [], visitor, name, node.description?.value);
8283
},
8384
};
8485
}
@@ -98,14 +99,18 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
9899
// Building schema for fields.
99100
const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
100101

102+
const maybeDescribe = this.config.withDescriptions && node.description?.value
103+
? `.describe('${escapeForDescribe(node.description.value)}')`
104+
: '';
105+
101106
switch (this.config.validationSchemaExportType) {
102107
case 'const':
103108
return (
104109
new DeclarationBlock({})
105110
.export()
106111
.asKind('const')
107112
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
108-
.withContent([`z.object({`, shape, '})'].join('\n'))
113+
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
109114
.string + appendArguments
110115
);
111116

@@ -116,7 +121,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
116121
.export()
117122
.asKind('function')
118123
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
119-
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
124+
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
120125
.string + appendArguments
121126
);
122127
}
@@ -139,6 +144,10 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
139144
// Building schema for fields.
140145
const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
141146

147+
const maybeDescribe = this.config.withDescriptions && node.description?.value
148+
? `.describe('${escapeForDescribe(node.description.value)}')`
149+
: '';
150+
142151
switch (this.config.validationSchemaExportType) {
143152
case 'const':
144153
return (
@@ -151,7 +160,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
151160
`z.object({`,
152161
indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
153162
shape,
154-
'})',
163+
`})${maybeDescribe}`,
155164
].join('\n'),
156165
)
157166
.string + appendArguments
@@ -169,7 +178,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
169178
indent(`return z.object({`),
170179
indent(`__typename: z.literal('${node.name.value}').optional(),`, 2),
171180
shape,
172-
indent('})'),
181+
indent(`})${maybeDescribe}`),
173182
].join('\n'),
174183
)
175184
.string + appendArguments
@@ -249,17 +258,22 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
249258
fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[],
250259
visitor: Visitor,
251260
name: string,
261+
description?: string,
252262
) {
253263
const typeName = visitor.prefixTypeNamespace(name);
254264
const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n');
255265

266+
const maybeDescribe = this.config.withDescriptions && description
267+
? `.describe('${escapeForDescribe(description)}')`
268+
: '';
269+
256270
switch (this.config.validationSchemaExportType) {
257271
case 'const':
258272
return new DeclarationBlock({})
259273
.export()
260274
.asKind('const')
261275
.withName(`${name}Schema: z.ZodObject<Properties<${typeName}>>`)
262-
.withContent(['z.object({', shape, '})'].join('\n'))
276+
.withContent([`z.object({`, shape, `})${maybeDescribe}`].join('\n'))
263277
.string;
264278

265279
case 'function':
@@ -268,15 +282,19 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor {
268282
.export()
269283
.asKind('function')
270284
.withName(`${name}Schema(): z.ZodObject<Properties<${typeName}>>`)
271-
.withBlock([indent(`return z.object({`), shape, indent('})')].join('\n'))
285+
.withBlock([indent(`return z.object({`), shape, indent(`})${maybeDescribe}`)].join('\n'))
272286
.string;
273287
}
274288
}
275289
}
276290

277291
function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string {
278292
const gen = generateFieldTypeZodSchema(config, visitor, field, field.type);
279-
return indent(`${field.name.value}: ${maybeLazy(visitor, field.type, gen)}`, indentCount);
293+
let schema = maybeLazy(visitor, field.type, gen);
294+
if (config.withDescriptions && field.description?.value) {
295+
schema = `${schema}.describe('${escapeForDescribe(field.description.value)}')`;
296+
}
297+
return indent(`${field.name.value}: ${schema}`, indentCount);
280298
}
281299

282300
function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string {

0 commit comments

Comments
 (0)