@nxgt/graphql-codegen-zod 0.0.0-stage → 0.1.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,460 @@
1
+ # Output
2
+
3
+ What the plugin writes from your schema and operations: the schemas, the
4
+ types, their names, and how defaults, recursion, `@oneOf` and custom scalars
5
+ come out.
6
+
7
+ ## The smallest example
8
+
9
+ ```graphql
10
+ # schema
11
+ input SignUpInput {
12
+ email: String! @constraint(format: "email")
13
+ name: String! @constraint(minLength: 2)
14
+ age: Int @constraint(min: 13, max: 120)
15
+ role: Role = USER
16
+ }
17
+ enum Role { ADMIN USER }
18
+ type Mutation { signUp(input: SignUpInput!): Boolean }
19
+ ```
20
+
21
+ ```graphql
22
+ # a document
23
+ mutation SignUp($input: SignUpInput!) { signUp(input: $input) }
24
+ ```
25
+
26
+ ```ts
27
+ // codegen.ts
28
+ import type { CodegenConfig } from '@graphql-codegen/cli';
29
+
30
+ const config: CodegenConfig = {
31
+ schema: 'src/schema/**/*.graphql',
32
+ documents: 'src/operations/**/*.graphql',
33
+ generates: {
34
+ 'src/generated/zod.ts': {
35
+ plugins: ['@nxgt/graphql-codegen-zod'],
36
+ config: { scalarSchemas: '@nxgt/graphql-scalars' },
37
+ },
38
+ },
39
+ };
40
+ export default config;
41
+ ```
42
+
43
+ The plugin writes, in this order: every enum, every input type, the
44
+ arguments of every object or interface field that takes any, then the
45
+ variables of every named operation. Each is a schema and its type.
46
+
47
+ ```ts
48
+ import { z } from "zod";
49
+
50
+ export const zRole = z.enum(["ADMIN", "USER"]);
51
+ export type Role = z.output<typeof zRole>;
52
+
53
+ export const zSignUpInput = z.strictObject({
54
+ email: z.email(),
55
+ name: z.string().min(2),
56
+ age: z.int32().lte(120).gte(13).nullish(),
57
+ role: zRole.prefault("USER").nullable(),
58
+ });
59
+ export type SignUpInput = z.output<typeof zSignUpInput>;
60
+
61
+ export const zMutationSignUpArgs = z.object({
62
+ get input() {
63
+ return zSignUpInput;
64
+ },
65
+ });
66
+ export type MutationSignUpArgs = z.output<typeof zMutationSignUpArgs>;
67
+
68
+ export const zSignUpMutationVariables = z.object({
69
+ get input() {
70
+ return zSignUpInput;
71
+ },
72
+ });
73
+ export type SignUpMutationVariables = z.input<typeof zSignUpMutationVariables>;
74
+ ```
75
+
76
+ The file imports only `zod` and the modules of your scalar schemas. Every
77
+ string in it is double-quoted. An input type is a `z.strictObject`: a field
78
+ the schema does not declare is refused, as `graphql` refuses it. The
79
+ arguments and variables objects stay `z.object`, because `graphql` ignores an
80
+ extra argument or variable.
81
+
82
+ A GraphQL description becomes a doc comment on the schema and its type, and on
83
+ each argument and input field that has one (line endings are normalised, so a
84
+ CRLF description gives the same comment). Every other import takes a name the
85
+ file does not declare: a clash is aliased, `money as money2`.
86
+
87
+ ```ts
88
+ export const zQueryUserArgs = z.object({
89
+ /** At least three characters. */
90
+ id: z.string().min(3),
91
+ });
92
+ ```
93
+
94
+ ## What each part is for
95
+
96
+ | Generated | From | Typed | Use it for |
97
+ | --- | --- | --- | --- |
98
+ | `z<Enum>` | an `enum` | `z.output` | `z.enum([...])` |
99
+ | `z<Input>` | an `input` | `z.output` | the value a resolver receives |
100
+ | `z<Parent><Field>Args` | an object or interface field with arguments | `z.output` | typing the resolver's arguments |
101
+ | `z<Operation><Kind>Variables` | a named operation in `documents` | `z.input` | checking a form before sending it |
102
+
103
+ `z.output` is what a resolver receives after `withValidation` parsed the
104
+ arguments; `z.input` is what a client sends. They differ where a default
105
+ applies, see [Defaults](#defaults).
106
+
107
+ ### On the server
108
+
109
+ ```ts
110
+ import type { MutationSignUpArgs } from './generated/zod';
111
+
112
+ export const signUp = (_: unknown, { input }: MutationSignUpArgs) =>
113
+ input.role === 'ADMIN'; // typed: 'ADMIN' | 'USER' | null
114
+ ```
115
+
116
+ ### On the client
117
+
118
+ ```ts
119
+ import { zSignUpMutationVariables } from './generated/zod';
120
+
121
+ const parsed = zSignUpMutationVariables.safeParse({
122
+ input: { email: 'nope', name: 'A' },
123
+ });
124
+ // parsed.success === false; parsed.error.issues lists the email and the name
125
+ ```
126
+
127
+ The schema is the same rule the server applies: a spec in this package
128
+ checks, operation by operation, that the client schema accepts exactly what
129
+ the server accepts.
130
+
131
+ ## Naming
132
+
133
+ Schemas are prefixed `z`; types keep the names `@graphql-codegen/typescript`
134
+ and `typescript-operations` give them.
135
+
136
+ | What | Schema | Type |
137
+ | --- | --- | --- |
138
+ | input | `zSignUpInput` | `SignUpInput` |
139
+ | enum | `zRole` | `Role` |
140
+ | field arguments | `zMutationSignUpArgs` | `MutationSignUpArgs` |
141
+ | operation variables | `zSignUpMutationVariables` | `SignUpMutationVariables` |
142
+
143
+ | Option | Type | Default | Effect |
144
+ | --- | --- | --- | --- |
145
+ | `schemaPrefix` | `string` | `'z'` | before each schema's name: `schemaPrefix: 'schema'` gives `schemaSignUpInput` |
146
+ | `namingConvention` | `'keep' \| 'change-case-all#<case>' \| (name: string) => string \| { typeNames, transformUnderscore, enumValues }` | PascalCase each part between underscores | how type names are cased, see below |
147
+ | `typesPrefix` | `string` | `''` | before each type's name: `ISignUpInput`; the schema's name follows: `zISignUpInput` |
148
+ | `typesSuffix` | `string` | `''` | after each type's name |
149
+ | `addUnderscoreToArgsType` | `boolean` | `false` | `Query_FindUserArgs` instead of `QueryFindUserArgs` |
150
+ | `dedupeOperationSuffix` | `boolean` | `false` | `findUserQuery` gives `FindUserQueryVariables`, not `FindUserQueryQueryVariables` |
151
+ | `omitOperationSuffix` | `boolean` | `false` | `FindUserVariables`: no `Query`, `Mutation` or `Subscription` |
152
+
153
+ ```ts
154
+ config: {
155
+ namingConvention: 'keep',
156
+ typesPrefix: 'I',
157
+ addUnderscoreToArgsType: true,
158
+ }
159
+ // type IQuery_find_userArgs, schema zIQuery_find_userArgs
160
+ ```
161
+
162
+ `namingConvention` takes every form the typescript plugins take for type
163
+ names:
164
+
165
+ | Form | Effect |
166
+ | --- | --- |
167
+ | `'keep'` | names as written |
168
+ | `'change-case-all#<case>'` or `'change-case#<case>'` | the case applied to each part between underscores. change-case-all's `<case>`: `camelCase`, `capitalCase`, `constantCase`, `dotCase`, `headerCase`, `noCase`, `paramCase`, `pascalCase`, `pathCase`, `sentenceCase`, `snakeCase`, `lowerCase`, `upperCase`, `lowerCaseFirst`, `upperCaseFirst`; change-case 5's names (`kebabCase`, `trainCase`, `pascalSnakeCase`, …) after `change-case#` |
169
+ | `(name) => string` | the function applied to each part between underscores |
170
+ | `{ typeNames, transformUnderscore, enumValues }` | with `typeNames` (a case or a function) the case applies to the whole name, underscores included, whatever `transformUnderscore` says, as in the typescript plugins; `'keep'` keeps it. Without `typeNames`: PascalCase each part, or the whole name with `transformUnderscore: true`. `enumValues` is ignored: enum values are written as they are |
171
+
172
+ ```ts
173
+ config: { namingConvention: 'change-case-all#camelCase' }
174
+ config: { namingConvention: { transformUnderscore: true } } // sign_upInput → SignUpInput
175
+ ```
176
+
177
+ Any other module string (`'my-module#myCase'`) throws, see
178
+ [Troubleshooting](../troubleshooting.md).
179
+
180
+ ### One file of its own
181
+
182
+ Generate into a file the typescript plugins do not write to: the names would
183
+ collide. This plugin's types can replace the typescript plugin's `*Args` and
184
+ `*Variables`, so the other plugins can be limited to the output types.
185
+
186
+ ## Defaults
187
+
188
+ A default value becomes `.prefault(<default>)`, then `.nullable()` when the
189
+ type is nullable. The key is optional on the way in and always present on the
190
+ way out, as `graphql` fills it.
191
+
192
+ ```graphql
193
+ input SignUpInput {
194
+ role: Role = USER
195
+ tags: [String!] = "new" @constraint(minItems: 1)
196
+ }
197
+ type Query { users(first: Int = 10 @constraint(min: 1, max: 50)): [String!]! }
198
+ ```
199
+
200
+ ```ts
201
+ export const zSignUpInput = z.strictObject({
202
+ role: zRole.prefault("USER").nullable(),
203
+ tags: z.union([z.array(z.string()).min(1), z.string().transform((value): unknown[] => [value]).pipe(z.array(z.string()).min(1))]).prefault(["new"]).nullable(),
204
+ });
205
+ export const zQueryUsersArgs = z.object({
206
+ first: z.int32().lte(50).gte(1).prefault(10).nullable(),
207
+ });
208
+ ```
209
+
210
+ ```ts
211
+ import { zQueryUsersArgs, type QueryUsersArgs, type SignUpMutationVariables } from './generated/zod';
212
+
213
+ zQueryUsersArgs.parse({}); // { first: 10 }
214
+
215
+ const first: QueryUsersArgs['first'] = 10; // number | null: always present
216
+ const sent: SignUpMutationVariables = { input: { email: 'a@b.co', name: 'Al' } }; // role may be left out
217
+ ```
218
+
219
+ A default is coerced as `graphql` coerces it, at any depth. A single value
220
+ given for a list is wrapped, once per level of nesting: `tags: [String!] = "new"`
221
+ is `.prefault(["new"])`, and `[[Int]] = 1` is `[[1]]`. This holds inside the
222
+ default literal of an input object:
223
+
224
+ ```graphql
225
+ input Prefs { tags: [String!], grid: [[Int]] }
226
+ input SignUpInput { prefs: Prefs = { tags: "x", grid: 1 } }
227
+ ```
228
+
229
+ ```ts
230
+ get prefs() {
231
+ return zPrefs.prefault({"tags":["x"],"grid":[[1]]}).nullable();
232
+ },
233
+ ```
234
+
235
+ An integer default past `Number.MAX_SAFE_INTEGER` given to an `ID` or a
236
+ custom scalar fails generation, since the server keeps every digit: write
237
+ it as a string, `n: Long = "9007199254740993"`.
238
+
239
+ An `Int` given for an `ID` is its string, as `graphql` makes it: `owner: ID = 7`
240
+ is `.prefault("7")`, `ids: [ID!] = 1` is `.prefault(["1"])`.
241
+
242
+ A schema built without SDL and without `@constraint` keeps its defaults:
243
+ introspected ones as their literal, a graphql 17 code-first one through
244
+ `valueToLiteral`, a graphql 16 one through `astFromValue`.
245
+
246
+ ## Recursion
247
+
248
+ An input type that refers to a type declared later is written with a Zod 4
249
+ getter, so the order of declaration does not matter. Only a member that
250
+ names an input type is a getter; the rest are plain properties.
251
+
252
+ An input type in a cycle, one that reaches itself through its fields
253
+ (`Filter.and: [Filter]`, or `Group` → `Member` → `Group`), cannot have its
254
+ type inferred once a field takes a single value for a list. Its types are
255
+ written out instead: `Filter`, what the resolver receives, and `FilterInput`,
256
+ what a client sends, and the schema is typed by both. The file's typecheck
257
+ proves the schema's types fit inside them; the package's type specs pin
258
+ them field by field.
259
+
260
+ ```graphql
261
+ """A filter that nests."""
262
+ input Filter {
263
+ and: [Filter!] @constraint(maxItems: 3)
264
+ name: String @constraint(startsWith: "n")
265
+ }
266
+ ```
267
+
268
+ ```ts
269
+ /** A filter that nests. */
270
+ export type Filter = {
271
+ and?: Array<Filter> | null | undefined;
272
+ name?: string | null | undefined;
273
+ };
274
+ /** A filter that nests. */
275
+ export type FilterInput = {
276
+ and?: FilterInput | Array<FilterInput> | null | undefined;
277
+ name?: string | null | undefined;
278
+ };
279
+ /** A filter that nests. */
280
+ export const zFilter: z.ZodType<Filter, FilterInput> = z.strictObject({
281
+ get and() {
282
+ return z.union([z.array(zFilter).max(3), z.custom<z.input<typeof zFilter>>((value) => !Array.isArray(value)).transform((value): unknown[] => [value]).pipe(z.array(zFilter).max(3))]).nullish();
283
+ },
284
+ name: z.string().startsWith("n").nullish(),
285
+ });
286
+ ```
287
+
288
+ **Trap:** typed `z.ZodType`, a schema in a cycle has no `.shape`,
289
+ `.extend` or `.pick`. Use the types, or rebuild the object you need. A type
290
+ outside a cycle keeps its `ZodObject`.
291
+
292
+ ## @oneOf
293
+
294
+ An input type marked `@oneOf` becomes a `z.union` of `z.strictObject`s, each
295
+ holding one member, set.
296
+
297
+ ```graphql
298
+ input Contact @oneOf {
299
+ email: String @constraint(format: "email")
300
+ phone: String @constraint(minLength: 6)
301
+ }
302
+ ```
303
+
304
+ ```ts
305
+ export const zContact = z.union([
306
+ z.strictObject({
307
+ email: z.email(),
308
+ }),
309
+ z.strictObject({
310
+ phone: z.string().min(6),
311
+ }),
312
+ ]);
313
+ ```
314
+
315
+ ```ts
316
+ zContact.safeParse({ email: 'a@b.co' }).success; // true
317
+ zContact.safeParse({ email: 'a@b.co', phone: '123456' }).success; // false
318
+ ```
319
+
320
+ ## Variables
321
+
322
+ A variable takes the constraints of every argument or input field it is
323
+ passed to, including inside list and object literals and in the fragments the
324
+ operation spreads.
325
+
326
+ ```graphql
327
+ query Users($name: String, $first: Int = 5) {
328
+ users(filter: { and: [{ name: $name }] }, first: $first) { id }
329
+ }
330
+ ```
331
+
332
+ `$name` is passed to `Filter.name`, which `startsWith: "n"`; `$first` to
333
+ `first`, which is `min: 1, max: 50`:
334
+
335
+ ```ts
336
+ export const zUsersQueryVariables = z.object({
337
+ name: z.string().startsWith("n").nullish(),
338
+ first: z.int32().lte(50).gte(1).prefault(5).nullable(),
339
+ });
340
+ export type UsersQueryVariables = z.input<typeof zUsersQueryVariables>;
341
+ ```
342
+
343
+ - Constraints come only from a variable's direct usages and from list or
344
+ object literals: `rate(ids: [$id])` with `ids: [ID!]! @constraint(minItems: 1, minLength: 3)`
345
+ gives `id: z.string().min(3)`. A variable of a non-list type is never
346
+ passed where a list is expected, so it takes no list rule.
347
+ - A variable passed to several places takes the constraints of all of them.
348
+ Two different `format`s cannot both apply, and fail generation, see
349
+ [Troubleshooting](../troubleshooting.md).
350
+ - An **anonymous operation gets no schema**: it has no name to give one.
351
+ Name it.
352
+ - The variable of a directive's argument (`@include(if: $x)`) carries no
353
+ constraint.
354
+
355
+ **Coercions.** A list also takes a single value, as `graphql` does, whatever
356
+ its items and at any depth (`"a"` for `[[String]]` is `[["a"]]`): the value
357
+ is wrapped, then checked as the list. From `Prefs.tags`:
358
+
359
+ ```ts
360
+ tags: z.union([z.array(z.string()), z.string().transform((value): unknown[] => [value]).pipe(z.array(z.string()))]).nullish(),
361
+ ```
362
+
363
+ `zPrefs.parse({ tags: "ok" })` gives `{ tags: ["ok"] }`, what the resolver
364
+ receives. A rule refuses a single value as the server does, at the same path:
365
+ `SignUpInput.tags` is `[String!] @constraint(minItems: 1, maxLength: 8)`, and
366
+ `tags: "toolongtag"` fails at `["input", "tags", 0]` with the server's
367
+ message.
368
+
369
+ A custom scalar's schema may decode (`DateTime`, a string to a `Date`), and
370
+ an input object's holds such scalars. For both, the single value reaches the
371
+ list's schema as it was sent, and is parsed once:
372
+
373
+ ```ts
374
+ dates: z.union([z.array(scalarSchemas.DateTime), z.custom<z.input<typeof scalarSchemas.DateTime>>((value) => !Array.isArray(value)).transform((value): unknown[] => [value]).pipe(z.array(scalarSchemas.DateTime))]).nullish(),
375
+ ```
376
+
377
+ ```ts
378
+ get contacts() {
379
+ return z.union([z.array(zContact).max(2), z.custom<z.input<typeof zContact>>((value) => !Array.isArray(value)).transform((value): unknown[] => [value]).pipe(z.array(zContact).max(2))]).nullish();
380
+ },
381
+ ```
382
+
383
+ One exception, refused: an `Int` for an `ID`. `graphql` takes it, the schema
384
+ is `z.string()`. Send ids as strings.
385
+
386
+ ## Scalars
387
+
388
+ Built-in scalars are written in full: `String` and `ID` as `z.string()`,
389
+ `Int` as `z.int32()` (32-bit, as graphql reads it), `Float` as `z.number()`, `Boolean` as
390
+ `z.boolean()`, each with its constraints.
391
+
392
+ A custom scalar needs a Zod schema from you, mapped in one of two ways:
393
+
394
+ | Option | Type | Effect |
395
+ | --- | --- | --- |
396
+ | `scalarSchemas` | `string` | a module exporting a `scalarSchemas` record keyed by GraphQL name; `@nxgt/graphql-scalars` exports one from 0.4.0 |
397
+ | `zodScalars` | `Record<string, string>` | `'<module>#<export>'` for one scalar; wins over the record |
398
+
399
+ ```ts
400
+ config: {
401
+ scalarSchemas: '@nxgt/graphql-scalars',
402
+ zodScalars: { Money: './money#moneySchema' },
403
+ }
404
+ ```
405
+
406
+ ```ts
407
+ // what the generated file imports
408
+ import { z } from "zod";
409
+ import { scalarSchemas } from "@nxgt/graphql-scalars";
410
+ import { moneySchema } from "./money";
411
+ ```
412
+
413
+ ```ts
414
+ birth: scalarSchemas.DateTime.nullish(),
415
+ price: moneySchema,
416
+ ```
417
+
418
+ - Two exports with the same name from different modules are aliased:
419
+ `import { schema } from "./money"; import { schema as schema2 } from "./cost";`.
420
+ - Module paths are written as they are in the generated file, so a relative
421
+ one (`./money`) is relative to **it**, not to `codegen.ts`.
422
+ - A custom scalar that is in neither fails generation, naming it. See
423
+ [Troubleshooting](../troubleshooting.md).
424
+ - The option is `zodScalars`, not `scalars`: a root `config: { scalars: {
425
+ DateTime: 'Date' } }` written for the typescript plugins reaches this
426
+ plugin too, and is ignored.
427
+ - The plugin loads the `scalarSchemas` module to check its keys when it can.
428
+ When it cannot (a `.ts` file under Node), it trusts the record, and the
429
+ generated file then fails to typecheck on a missing key.
430
+ - A rule on a custom scalar (`@constraint` on a `DateTime` field) is refused,
431
+ as `withValidation` refuses it; check the value in the scalar's own schema.
432
+
433
+ ## Errors
434
+
435
+ A schema that declares `@constraint` must be read from its SDL files: an
436
+ introspected schema, or one loaded from a URL, has no directives to read and
437
+ fails generation. Without `@constraint` it works.
438
+
439
+ The plugin refuses a schema `withValidation` would refuse, with the same
440
+ message: a rule on the wrong type, `@constraint` on an output field, two
441
+ declarations of the directive. Those messages are in
442
+ [`@nxgt/graphql-validation`'s troubleshooting](https://github.com/softistx/nxgt-graphql/blob/develop/packages/graphql-validation/docs/troubleshooting.md).
443
+ The messages of this plugin are in [Troubleshooting](../troubleshooting.md).
444
+
445
+ ## The plugin function
446
+
447
+ ```ts
448
+ import { plugin, type CodegenZodConfig, type DocumentFile } from '@nxgt/graphql-codegen-zod';
449
+
450
+ declare function plugin(
451
+ schema: GraphQLSchema,
452
+ documents: readonly DocumentFile[], // { document?: DocumentNode; location?: string }
453
+ config?: CodegenZodConfig,
454
+ info?: { outputFile?: string },
455
+ ): Promise<string>;
456
+ ```
457
+
458
+ graphql-codegen calls it; call it yourself to generate in a script or a test.
459
+ `info.outputFile` lets a relative `scalarSchemas` path be resolved to check
460
+ its keys.
@@ -0,0 +1,35 @@
1
+ # Roadmap
2
+
3
+ What `@nxgt/graphql-codegen-zod` is heading for, phrased as what you get.
4
+
5
+ ## Now
6
+
7
+ - **The first release, 0.1.0.** Zod 4 schemas and types for enums, input
8
+ types, field arguments and operation variables, carrying every
9
+ `@constraint` of `@nxgt/graphql-validation`, so the client refuses what the
10
+ server refuses.
11
+
12
+ ## Next
13
+
14
+ Candidates, not commitments.
15
+
16
+ - **Emit only some parts**, an option to write just the arguments, or just
17
+ the variables, of a file.
18
+ - **Your own formats**, once `@nxgt/graphql-validation` supports custom
19
+ `@constraint(format: "...")` values; the plugin will carry them.
20
+
21
+ ## Later
22
+
23
+ Nothing yet.
24
+
25
+ ## Not planned
26
+
27
+ - **An `Int` for an `ID` on the client.** An id is a string in what the
28
+ client sends, as in what the resolver receives.
29
+
30
+ - **Zod 3.** The schemas use Zod 4's getters, `prefault`, `z.strictObject` and
31
+ `z.email()`; they have no Zod 3 form.
32
+
33
+ ## Shipped
34
+
35
+ Nothing yet.