Plugins

Zod validation plugin

Validate field arguments and input fields with a validate option that maps onto zod constraints.

The validation plugin is now the recommended way to validate a schema. It supports zod alongside several other validation libraries. The zod plugin keeps working; use it only on schemas already built around it.

The zod plugin validates field arguments and input fields with zod. You attach a validate option wherever you accept input (a single argument, a whole field's args, an input object, or one of its fields) and the plugin builds a zod validator that runs before your resolver. It does not re-export zod; instead validate takes a small options object whose keys map onto the zod methods you already know (min, max, email, regex, and so on), or an actual zod schema when you want the full API.

Install

npm install --save zod @pothos/plugin-zod

Setup

Add the plugin, then optionally hand it a validationError callback to shape what clients see when validation fails.

import SchemaBuilder from '@pothos/core';
import ZodPlugin from '@pothos/plugin-zod';

const builder = new SchemaBuilder<{ Context: { userId: string } }>({
  plugins: [ZodPlugin],
  zod: {
    // Runs when validation fails. Return a string or Error, or throw your own.
    // The default is to throw the raw ZodError.
    validationError: (zodError, _args, _context, _info) => zodError.issues[0].message,
  },
});

validationError receives the ZodError, the field's args, the context, and the GraphQL info. Return a string (thrown as a PothosValidationError), return an Error instance (thrown as-is), or throw directly. Skip it to surface the raw zod error.

Validating a single argument

Add validate to any argument. The keys you pass are constraints for that argument's type; here an email string capped at 254 characters.

builder.queryType({
  fields: (t) => ({
    playerByEmail: t.boolean({
      args: {
        email: t.arg.string({
          validate: {
            email: true,
            maxLength: 254,
          },
        }),
      },
      resolve: () => true,
    }),
  }),
});

Validating all arguments together

Cross-field rules ("at least one of these," "start before end") belong on the field's own validate, which receives the whole args object. It can be a function, or a [function, options] pair when you want a message.

builder.mutationType({
  fields: (t) => ({
    inviteToTeam: t.boolean({
      args: {
        email: t.arg.string({ validate: { email: true } }),
        phone: t.arg.string(),
      },
      // Require at least one contact method across the two args.
      validate: [
        (args) => !!args.email || !!args.phone,
        { message: 'Provide either an email address or a phone number' },
      ],
      resolve: () => true,
    }),
  }),
});

Custom messages

Every constraint accepts either a bare value or a [value, { message }] pair. The pair form is a Constraint; the options object is passed straight to the underlying zod method, so anything zod's method accepts (a message, and a path for object refinements) works.

t.arg.int({
  validate: {
    min: [0, { message: 'jersey number cannot be negative' }],
    max: [99, { message: 'jersey number must be under 100' }],
    int: true,
  },
});

Lists

List arguments validate the list and its items in one options object. Constraints like minLength / maxLength / length apply to the array; items carries the constraints for each element.

builder.mutationType({
  fields: (t) => ({
    setRoster: t.boolean({
      args: {
        emails: t.arg.stringList({
          validate: {
            maxLength: 12,
            items: {
              email: true,
            },
          },
        }),
      },
      resolve: () => true,
    }),
  }),
});

Input objects

validate works the same on an input type and on its fields. Put per-field rules on each field, and cross-field rules on the input type itself, where the callback receives the whole object.

const RegisterTeamInput = builder.inputType('RegisterTeamInput', {
  fields: (t) => ({
    name: t.string({ validate: { minLength: 3, maxLength: 40 } }),
    contactEmail: t.string({ validate: { email: true } }),
    backupEmail: t.string({ required: false, validate: { email: true } }),
  }),
  // Runs against the assembled input object.
  validate: [
    (input) => input.contactEmail !== input.backupEmail,
    { message: 'backup email must differ from contact email' },
  ],
});

Bring your own zod schema

When the built-in constraints run out (unions, branded types, transform, anything zod can express), pass a real schema with the schema key instead. It works on a single argument:

import { z } from 'zod';

t.arg.int({
  validate: {
    schema: z.number().int().max(5),
  },
});

...or on the whole field, validating every argument at once:

builder.queryType({
  fields: (t) => ({
    signIn: t.boolean({
      args: {
        email: t.arg.string(),
        password: t.arg.string(),
      },
      validate: {
        schema: z.object({
          email: z.string().email(),
          password: z.string().min(8),
        }),
      },
      resolve: () => true,
    }),
  }),
});

You can combine schema with the constraint keys. The plugin pipes your schema into the generated validator, so both run. Validation runs as an async check just before your resolver; refinements may return a Promise<boolean>, and the parsed value (including anything a transform rewrites) becomes the args your resolver receives.

Constraint reference

validate accepts a bare refinement function, an array of them, or an options object. The options object always allows these keys:

KeyTypePurpose
type'number' | 'bigint' | 'boolean' | 'date' | 'string' | 'object' | 'array'Pin the base zod type. See How it works for why this matters.
refinefunction or [function, { message?, path? }], or an array of eitherA predicate handed to zod's refine. Receives the validated value; returns boolean or Promise<boolean>.
schemaZodTypeA zod schema piped into the generated validator.

The remaining keys depend on the field's type. Each value is a Constraint, either a bare value or [value, { message }]:

TypeAdditional keys
Numbermin, max, int, positive, nonnegative, negative, nonpositive
StringminLength, maxLength, length, email, url, uuid, regex
ArrayminLength, maxLength, length, items (a nested ValidationOptions for each element)
BigInt(base keys only)
Boolean(base keys only)
Date(base keys only)
Object(base keys only)

How it works

Each argument and input field builds its own zod validator from its validate options. At runtime the plugin has no access to the JavaScript type behind a GraphQL type, so when you pass plain constraints it builds a union of every base type that could satisfy them. A lone maxLength, for example, fits both strings and arrays:

z.union([z.string().max(5), z.array(z.unknown()).max(5)]);

An email constraint only fits strings, so the union collapses to one member. When the argument is not required, the whole validator is wrapped .optional().nullable() rather than folded into the union.

Set type to skip the guessing and pin one base type:

// { validate: { type: 'string', maxLength: 5 } } builds:
z.string().max(5);

Three cases sidestep the union entirely:

  • Input object args and fields always validate with z.looseObject(...).
  • List args and fields always validate with .array().
  • Refinement-only validators (a bare function, or an options object with just refine and/or schema) validate against z.unknown(), since no constraint narrows the type.

A schema is piped into whatever the plugin generates (yourSchema.pipe(generated)), so your schema and the constraints both run.

Older releases wrapped optional validators as z.union([z.null(), z.undefined(), …]) and merged schema with z.intersection. On zod 4 the plugin uses .optional().nullable() and .pipe() instead; the shapes above reflect current behavior.

Sharing schemas with client code

To reuse a validator on the client, write it as an ordinary zod schema in a shared module, then attach it with schema:

// shared/validators.ts
import { z } from 'zod';

export const jerseyNumber = z.number().int().min(0).max(99);
// server
import { jerseyNumber } from './shared/validators';

t.arg.int({
  validate: { schema: jerseyNumber },
});
// client
import { jerseyNumber } from './shared/validators';

jerseyNumber.parse(23); // pass
jerseyNumber.parse(100); // throws

If you would rather share the constraint options object, the plugin exports createZodSchema to turn one into a zod schema on demand. Type the options with the exported ValidationOptions:

// shared/validators.ts
import type { ValidationOptions } from '@pothos/plugin-zod';

export const jerseyNumberOptions: ValidationOptions<number> = {
  min: 0,
  max: 99,
  int: true,
};
// server
import { jerseyNumberOptions } from './shared/validators';

t.arg.int({ validate: jerseyNumberOptions });
// client
import { createZodSchema } from '@pothos/plugin-zod';
import { jerseyNumberOptions } from './shared/validators';

const validator = createZodSchema(jerseyNumberOptions);

validator.parse(23); // pass
validator.parse(100); // throws