Plugins

Validation plugin

Validate arguments, input fields, and input objects with Zod or any Standard Schema library.

GraphQL's type system checks shapes, not rules. It will accept jersey: 250 or email: "nope" as long as the types line up. The validation plugin closes that gap: attach a Standard Schema (a Zod, Valibot, or ArkType schema) to any argument, input field, or input type, and Pothos runs it before your resolver ever sees the value.

Every attachment point (an argument, an input field, an input type, or a whole field's args) takes the same validate option.

Setup

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

Swap zod for valibot or arktype; the plugin only cares that the library implements Standard Schema. Register the plugin like any other:

import ValidationPlugin from '@pothos/plugin-validation';
import { z } from 'zod'; // or valibot, arktype, ...

const builder = new SchemaBuilder({
  plugins: [ValidationPlugin],
});

Validating arguments

Pass a schema as the validate option on any argument. If the value fails, the resolver never runs and the client gets an error. Here a roster mutation guards the player's name length, email format, and jersey range:

builder.mutationType({
  fields: (t) => ({
    registerPlayer: t.field({
      type: Player,
      args: {
        name: t.arg.string({
          required: true,
          validate: z.string().min(2).max(50),
        }),
        email: t.arg.string({
          required: true,
          validate: z.email(),
        }),
        jersey: t.arg.int({
          required: true,
          validate: z.number().int().min(0).max(99),
        }),
      },
      resolve: (_root, args) => {
        const player: IPlayer = {
          id: String(roster.size + 1),
          name: args.name,
          email: args.email,
          jersey: args.jersey,
        };

        roster.set(player.id, player);

        return player;
      },
    }),
  }),
});

Every point that accepts validate: also has a chained .validate() equivalent. The two are interchangeable for plain checks; use chaining when you want to transform the value.

Transforming as you validate

A Standard Schema can change a value's type, and Pothos hands the transformed result to your resolver. Chain .validate() to convert a comma-separated string into an array:

const tags = t.arg.string()
  .validate(z.string().transform((str) => str.split(',').map((s) => s.trim())));

// In the resolver, args.tags is now string[]

Chaining .validate() more than once runs the schemas in order, so each transform feeds the next.

Validating across arguments

Per-argument schemas can't express "at least one of these." For cross-argument rules, put a validate schema on the field itself; it receives the whole args object:

builder.queryField('findPlayer', (t) =>
  t.boolean({
    args: {
      email: t.arg.string(),
      jersey: t.arg.int(),
    },
    // Require at least one lookup key
    validate: z
      .object({ email: z.string().optional(), jersey: z.number().optional() })
      .refine((args) => args.email != null || args.jersey != null, {
        message: 'Provide an email or a jersey number',
      }),
    resolve: () => true,
  }),
);

To transform all arguments together, the field-level validate option can't help; it can't retype the args. Use t.validate(args, schema) instead, which wraps the args map and threads the transformed shape through to the resolver:

builder.queryField('findPlayer', (t) =>
  t.string({
    args: t.validate(
      { email: t.arg.string(), jersey: t.arg.int() },
      z
        .object({ email: z.string().optional(), jersey: z.number().optional() })
        .transform((args) => ({
          filter: { email: args.email?.toLowerCase(), jersey: args.jersey },
        })),
    ),
    // args is now { filter: { email?: string; jersey?: number } }
    resolve: (_root, args) => JSON.stringify(args.filter),
  }),
);

Validating input types

Input types take validate in both places an argument does: on each field, and on the type as a whole:

const signUpRules = z
  .object({ email: z.string(), jersey: z.number(), backupJersey: z.number() })
  .refine((input: { jersey: number; backupJersey: number }) => input.jersey !== input.backupJersey, {
    message: 'Primary and backup jersey numbers must differ',
  });

const SignUp = builder.inputType('SignUp', {
  fields: (t) => ({
    email: t.string({
      required: true,
      validate: z.email(),
    }),
    jersey: t.int({ required: true }),
    backupJersey: t.int({ required: true }),
  }),
  validate: signUpRules,
});

The field-level z.email() runs per field; the type-level signUpRules sees every field at once, which is where cross-field rules like "primary and backup jersey numbers must differ" belong.

Both levels transform, too. A type-level .validate(z.object().transform(...)) can reshape the whole input (parsing a raw form into a normalized record), and a chained input-field .validate() can convert a value, such as an ISO date string into a Date:

const RawSignUp = builder.inputType('RawSignUp', {
  fields: (t) => ({
    joinedOn: t.string()
      .validate(z.string().regex(/^\d{4}-\d{2}-\d{2}$/))
      .validate(z.string().transform((str) => new Date(str))),
  }),
});

Choosing a validation library

Any Standard Schema library works; pick on ergonomics and bundle size, not compatibility:

  • Zod: TypeScript-first, the most feature-complete option.
  • Valibot: modular and tree-shakeable, for bundle-size-sensitive builds.
  • ArkType: 1:1 TypeScript syntax, optimized editor-to-runtime.

Because the plugin targets the Standard Schema interface rather than any one library, you can mix libraries in a single schema, or swap later without touching Pothos.

Customizing validation errors

By default a failed validation throws an InputValidationError, a PothosValidationError whose message lists each failing path and reason. Override the validationError option to reshape that into whatever your API and monitoring expect:

const builder = new SchemaBuilder({
  plugins: [ValidationPlugin],
  validation: {
    validationError: (result, args, context) => {
      // result is the Standard Schema failure — result.issues[] carries path + message
      return new Error(result.issues.map((issue) => issue.message).join('; '));
    },
  },
});

The handler runs with the raw failure result, the field's args, and the request context, enough to log, tag, or branch on the operation. It can:

  • return an Error, thrown as-is,
  • return a string, wrapped in a PothosValidationError, or
  • throw its own error directly.

Whatever it returns follows normal error handling from there: mask it in production, or surface it as a typed result with plugin-errors.

Execution order

When a field carries validation at several levels, Pothos runs them from the inside out so that each transform is applied before the next schema sees the value:

  1. Input fields validate first.
  2. Input types validate once their fields pass.
  3. Arguments validate next.
  4. Field-level validate / t.validate runs last, over the fully validated args.

Schemas stacked on the same field or type run in sequence, so transforms chain. Schemas on separate fields or arguments run in parallel, and their failures merge into a single set of issues, so one response reports every problem at once rather than only the first.