Validation plugin

Validation plugin docs for Pothos

Validate arguments and input objects before a field resolver runs. Attach a schema from any library that implements Standard Schema, including Zod, Valibot, and ArkType. Validation can be asynchronous, and chained validators can transform the values passed to your resolver. If validation fails, the resolver does not run.

Usage

Install

To use the validation plugin, you'll need to install the validation plugin and a compatible validation library:

npm install --save @pothos/plugin-validation zod
# OR
npm install --save @pothos/plugin-validation valibot
# OR
npm install --save @pothos/plugin-validation arktype

Setup

import SchemaBuilder from '@pothos/core';
import ValidationPlugin from '@pothos/plugin-validation';
import { z } from 'zod'; // or your preferred validation library

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

builder.queryType({
  fields: (t) => ({
    simple: t.boolean({
      args: {
        // Validate individual arguments
        email: t.arg.string({
          required: true,
          validate: z.string().email(),
        }),
      },
      resolve: () => true,
    }),
  }),
});

Validation API Overview

The validation plugin supports validating inputs and arguments in several different ways:

  • Argument validation: t.arg.string({ validate: schema }) or t.arg.string().validate(schema) - Validate individual arguments
  • Validate all field args: t.field({ args, validate: schema, ... }) or t.field({ args: t.validate(args, schema), ... }) - Validate all arguments together
  • Input type validation: builder.inputType('Input', { validate: schema, ... }) or builder.inputType('Input', { ... }).validate(schema) - Validate entire input objects
  • Input field validation: t.string({ validate: schema }) or t.string().validate(schema) - Validate individual input type fields

Each example below is independent. Reuse the setup imports and builder configuration, then use the example's query and input definitions in place of any earlier ones.

Validation Patterns

Argument Validation

Validate each field argument independently using either the object syntax or chaining API:

builder.queryType({
  fields: (t) => ({
    user: t.string({
      args: {
        email: t.arg.string({
          required: true,
          validate: z.string().email(),
        }),
        name: t.arg.string({ required: true })
          .validate(z.string().min(2).max(50)),
      },
      resolve: (_, args) => `User: ${args.name}`,
    }),
  }),
});

Data Transformation with Argument Validation

When using the chaining API, you can transform data as part of the validation process:

builder.queryType({
  fields: (t) => ({
    processData: t.string({
      args: {
        // Convert comma-separated string to array
        tags: t.arg.string({ required: true })
          .validate(z.string().transform(str => str.split(',').map(s => s.trim()))),
      },
      resolve: (_, args) => {
        return `Processed ${args.tags.length} tags`;
      },
    }),
  }),
});

Validating all Field Arguments Together

Pass validate on the output field to check its arguments together. Optional GraphQL arguments can be omitted or explicitly null; use a schema that accepts both when those are valid inputs.

builder.queryType({
  fields: (t) => ({
    contact: t.boolean({
      args: {
        email: t.arg.string(),
        phone: t.arg.string(),
      },
      // Ensure at least one contact method is provided
      validate: z
        .object({
          email: z.string().nullish(),
          phone: z.string().nullish(),
        })
        .refine(
          (args) => !!args.phone || !!args.email,
          { message: 'Must provide either phone or email' }
        ),
      resolve: () => true,
    }),
  }),
});

With transforms

Use t.validate(args, schema) to transform all arguments and infer the transformed resolver arguments:

builder.queryType({
  fields: (t) => ({
    user: t.string({
      args: t.validate({
        email: t.arg.string(),
        phone: t.arg.string(),
      },
        z.object({
          email: z.string().nullish(),
          phone: z.string().nullish(),
        })
        .refine(
          (args) => !!args.phone || !!args.email,
          { message: 'Must provide either phone or email' }
        )
        .transform((args) => ({
          filter: {
            email: args.email ? args.email.toLowerCase() : undefined,
            phone: args.phone ? args.phone.replace(/\D/g, '') : undefined,
          },
        }))
      ),
      resolve: (_, args) => {
        // args has transformed shape:
        // { filter: { email?: string, phone?: string } }
        return `User filter: ${JSON.stringify(args.filter)}`;
      },
    }),
  }),
});

Input Type Validation

Validate entire input objects with complex validation logic using either object syntax or chaining:

// Object syntax
const UserInput = builder.inputType('UserInput', {
  fields: (t) => ({
    name: t.string({ required: true }),
    age: t.int({ required: true }),
  }),
  validate: z
    .object({
      name: z.string(),
      age: z.number(),
    })
    .refine((user) => user.name !== 'admin', {
      message: 'Username "admin" is not allowed',
    })
});

Input Type Transformation

Transform entire input types:

const UserInput = builder.inputType('RawUserInput', {
  fields: (t) => ({
    fullName: t.string({ required: true }),
    email: t.string({ required: true }),
  }),
}).validate(
  z.object({
    fullName: z.string(),
    email: z.string().email(),
  }).transform(data => ({
    firstName: data.fullName.split(' ')[0],
    lastName: data.fullName.split(' ').slice(1).join(' '),
    email: data.email.toLowerCase(),
  }))
);

builder.queryType({
  fields: (t) => ({
    previewUser: t.string({
      args: {
        userData: t.arg({ type: UserInput, required: true }),
      },
      resolve: (_, args) => {
        // args.userData has transformed shape:
        // { firstName: string, lastName: string, email: string }
        return `User preview: ${args.userData.firstName} ${args.userData.lastName} <${args.userData.email}>`;
      },
    }),
  }),
});

Input Field Validation

Validate individual fields within input types:

const UserInput = builder.inputType('UserInput', {
  fields: (t) => ({
    name: t.string({
      required: true,
      validate: z.string().min(2).refine(
        (name) => name[0].toUpperCase() === name[0],
        { message: 'Name must be capitalized' }
      ),
    })
  }),
});

Input Field Transformation

Transform field values during validation:

const UserInput = builder.inputType('UserInput', {
  fields: (t) => ({
    birthDate: t.string({ required: true })
      .validate(z.iso.date())
      .validate(z.string().transform(str => new Date(str))),
  }),
});

Validation before mutation side effects

Validation finishes before a mutation resolver runs. A rejected input therefore cannot trigger writes placed inside that resolver. The following example trims accepted names and stores them in an in-memory array; invalid names or email addresses produce typed validation issues.

This separate builder uses the errors plugin integration. Install @pothos/plugin-errors alongside the validation plugin and Zod. Setting unsafelyHandleInputErrors: true allows validation details to be returned before field authorization hooks run, so only enable it when those details may be public.

import SchemaBuilder from '@pothos/core';
import ErrorsPlugin from '@pothos/plugin-errors';
import ValidationPlugin, {
  InputValidationError,
  type StandardSchemaV1,
} from '@pothos/plugin-validation';
import { z } from 'zod';
// This public example deliberately exposes validation details before authorization.
const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin, ValidationPlugin],
  errors: { unsafelyHandleInputErrors: true },
});
const names: string[] = [];

const Issue = builder.objectRef<StandardSchemaV1.Issue>('ValidationIssue').implement({
  fields: (t) => ({
    message: t.exposeString('message'),
    path: t.stringList({
      resolve: (issue) =>
        issue.path?.map((part) => String(typeof part === 'object' ? part.key : part)) ?? [],
    }),
  }),
});
builder.objectType(InputValidationError, {
  name: 'InputValidationError',
  fields: (t) => ({ issues: t.field({ type: [Issue], resolve: (error) => error.issues }) }),
});

const Registration = builder.inputType('Registration', {
  fields: (t) => ({
    name: t.string({ required: true }).validate(z.string().trim().min(3, 'Name is too short')),
    email: t.string({ required: true, validate: z.email('Enter a valid email') }),
  }),
});
builder.mutationType({
  fields: (t) => ({
    register: t.string({
      args: { input: t.arg({ type: Registration, required: true }) },
      errors: { types: [InputValidationError] },
      resolve: (_, { input }) => {
        names.push(input.name);
        return input.name;
      },
    }),
  }),
});

Plugin Options

'validationError'

By default, failed validation throws InputValidationError, which contains Standard Schema issues with their messages and paths. Set validationError to return your own error or message.

const builder = new SchemaBuilder({
  plugins: [ValidationPlugin],
  validation: {
    validationError: (validationResult, args, context) => {
      // validationResult contains the standard-schema validation result
      return new Error(`Validation failed: ${validationResult.issues.map(i => i.message).join(', ')}`);
    },
  },
});

Return Values

Your error handler can return:

  • Error object: Return a custom Error instance
  • String: Return a string message (will be wrapped in a PothosValidationError)
  • Throw: Throw an error directly

To return validation failures as typed GraphQL results, see the errors plugin integration. This requires unsafelyHandleInputErrors, because input validation runs before field authorization hooks.

Validation Execution Order

Understanding when and how validations are executed:

  1. Input Field Validation: Individual input fields are validated first
  2. Input Type Validation: Whole input object validation runs after field validation passes
  3. Argument Validation: Individual field arguments are validated
  4. Field-Level Validation: The output field's validate option and t.validate() run last

When there are multiple validations for the same field or type, they are executed in order, so that any transforms are applied before passing to the next schema. Validations for separate fields or arguments are executed in parallel, and their results are merged into a single set of issues.