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 arktypeSetup
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 })ort.arg.string().validate(schema)- Validate individual arguments - Validate all field args:
t.field({ args, validate: schema, ... })ort.field({ args: t.validate(args, schema), ... })- Validate all arguments together - Input type validation:
builder.inputType('Input', { validate: schema, ... })orbuilder.inputType('Input', { ... }).validate(schema)- Validate entire input objects - Input field validation:
t.string({ validate: schema })ort.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:
- Input Field Validation: Individual input fields are validated first
- Input Type Validation: Whole input object validation runs after field validation passes
- Argument Validation: Individual field arguments are validated
- Field-Level Validation: The output field's
validateoption andt.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.