GraphQL Mutation Validation Pattern: Centralized Input Validation
Centralize GraphQL mutation validation with reusable rules, custom scalars, and structured errors. Includes TypeScript examples and best practices.
Overview
GraphQL mutations take inputs that need a sanity check before the real work starts. Without a centralized approach, every resolver ends up re-checking the same things: required fields, email format, string length, and numeric range. That duplication produces inconsistent rules and error formats across the API.
The GraphQL mutation validation pattern moves input validation into a reusable pipeline. Each mutation passes its input through a set of rules and receives a structured list of field-level errors with machine-readable codes. Then resolvers can get on with business logic and stop worrying about input checks.
When to Use
Reach for this pattern when you’ve got several mutations that accept user input and the same validation rules keep cropping up in different resolvers. It also fits when clients need field-level error details to render forms, when you want consistent error codes across the API, and when you want validation kept out of business logic.
When to Avoid
Avoid it for a single mutation with one or two simple fields; the extra overhead may not be worth it. Also skip
it if the database or schema already handles validation for you. And don’t introduce
it unless the client can consume structured field-level errors — a generic BAD_REQUEST response is often enough
for internal tools.
Solution
Validation Framework
import { GraphQLError } from 'graphql';
type ValidationRule = {
field: string;
rule: (value: any, input: Record<string, any>) => boolean;
message: string;
code: string;
};
function validateInput(input: Record<string, any>, rules: ValidationRule[]): void {
const errors: Array<{ field: string; message: string; code: string }> = [];
for (const rule of rules) {
const value = input[rule.field];
if (!rule.rule(value, input)) {
errors.push({
field: rule.field,
message: rule.message,
code: rule.code,
});
}
}
if (errors.length > 0) {
throw new GraphQLError('Validation failed', {
extensions: {
code: 'VALIDATION_ERROR',
fields: errors.map(e => e.field),
errors,
timestamp: new Date().toISOString(),
},
});
}
}
Reusable Validation Rules
const rules = {
required: (field: string): ValidationRule => ({
field,
rule: (value) => value !== undefined && value !== null && value !== '',
message: `${field} is required`,
code: 'REQUIRED',
}),
minLength: (field: string, min: number): ValidationRule => ({
field,
rule: (value) => typeof value === 'string' && value.length >= min,
message: `${field} must be at least ${min} characters`,
code: 'MIN_LENGTH',
}),
maxLength: (field: string, max: number): ValidationRule => ({
field,
rule: (value) => typeof value === 'string' && value.length <= max,
message: `${field} must be at most ${max} characters`,
code: 'MAX_LENGTH',
}),
email: (field: string): ValidationRule => ({
field,
rule: (value) => typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: `${field} must be a valid email address`,
code: 'INVALID_EMAIL',
}),
range: (field: string, min: number, max: number): ValidationRule => ({
field,
rule: (value) => typeof value === 'number' && value >= min && value <= max,
message: `${field} must be between ${min} and ${max}`,
code: 'OUT_OF_RANGE',
}),
url: (field: string): ValidationRule => ({
field,
rule: (value) => {
if (!value) return true;
try {
new URL(value);
return true;
} catch {
return false;
}
},
message: `${field} must be a valid URL`,
code: 'INVALID_URL',
}),
};
Using Validators in Resolvers
const resolvers = {
Mutation: {
createUser: async (_, { input }, context) => {
validateInput(input, [
rules.required('name'),
rules.minLength('name', 2),
rules.maxLength('name', 100),
rules.required('email'),
rules.email('email'),
rules.maxLength('bio', 500),
rules.url('website'),
]);
const existing = await context.db.query(
'SELECT id FROM users WHERE email = $1',
[input.email]
);
if (existing.length > 0) {
throw new GraphQLError('Email already registered', {
extensions: {
code: 'DUPLICATE_EMAIL',
field: 'email',
timestamp: new Date().toISOString(),
},
});
}
const user = await context.db.query(
'INSERT INTO users (name, email, bio, website) VALUES ($1, $2, $3, $4) RETURNING *',
[input.name, input.email, input.bio || null, input.website || null]
);
return user[0];
},
updatePost: async (_, { input }, context) => {
validateInput(input, [
rules.required('id'),
rules.required('title'),
rules.minLength('title', 5),
rules.maxLength('title', 200),
rules.required('body'),
rules.minLength('body', 50),
rules.range('status', 0, 3),
]);
const post = await context.db.query(
'UPDATE posts SET title = $1, body = $2, status = $3 WHERE id = $4 RETURNING *',
[input.title, input.body, input.status, input.id]
);
if (post.length === 0) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND', timestamp: new Date().toISOString() },
});
}
return post[0];
},
},
};
Schema-Level Validation with Custom Scalars
import { GraphQLScalarType, GraphQLError } from 'graphql';
const EmailScalar = new GraphQLScalarType({
name: 'Email',
description: 'A validated email address',
parseValue: (value: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new GraphQLError('Invalid email format', {
extensions: { code: 'INVALID_EMAIL', field: 'email' },
});
}
return value.toLowerCase().trim();
},
parseLiteral: (ast: any) => {
if (ast.kind !== 'StringValue') {
throw new GraphQLError('Email must be a string', {
extensions: { code: 'INVALID_TYPE' },
});
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ast.value)) {
throw new GraphQLError('Invalid email format', {
extensions: { code: 'INVALID_EMAIL' },
});
}
return ast.value.toLowerCase().trim();
},
serialize: (value: string) => value,
});
const typeDefs = `
scalar Email
input CreateUserInput {
name: String!
email: Email!
bio: String
website: String
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
`;
Client-Side Error Handling
async function createUser(input: CreateUserInput) {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id name email }
}`,
variables: { input },
}),
});
const { data, errors } = await response.json();
if (errors) {
const validationError = errors.find(e => e.extensions?.code === 'VALIDATION_ERROR');
if (validationError) {
const fieldErrors = validationError.extensions.errors;
for (const err of fieldErrors) {
setFieldError(err.field, err.message);
}
return null;
}
throw new Error(errors[0].message);
}
return data.createUser;
}
Explanation
The pattern organizes validation into three layers.
Schema-level validation uses custom scalars such as Email to reject invalid values during parsing, before the
resolver runs. This is the earliest gate that blocks bad input.
Rule-based validation calls validateInput against the input object. Each rule checks one field; if it fails, it
adds an error. Every rule runs, so the client receives the full list of errors in one shot.
Business validation covers checks that need the database, such as duplicate emails or foreign-key existence. These run in the resolver after the rule-based validation passes and usually return a single-field error tied to a specific business rule.
All validation errors share the same extension format with a code, a fields array, and a detailed errors array.
Clients can switch on the code and render field-level messages.
Variants
Custom scalars validate at the schema parse stage. They fit strict format checks such as email, URL, or date. Rule-based validators kick in at the start of the resolver and handle field-level rules like required fields, length, and range. Schema directives are a good place for authorization and rate limiting. Zod fits TypeScript projects that want type inference; Joi or Yup work better for complex nested objects.
A rule-based pipeline handles most cases.
Best Practices
Validate early with custom scalars so bad values never reach the resolver. Return every validation error in one
response, so the client can flag all the bad fields at once. Use consistent error codes such as REQUIRED,
MIN_LENGTH, and INVALID_EMAIL across all mutations. Keep format checks in validators and business checks in
resolvers. After validation passes, sanitize inputs — trim strings, lowercase emails, normalize URLs — but before
writing to the database.
Common Mistakes
Throwing on the first error forces clients into a submit-fix-resubmit loop, so collect every failure first. Mixing
validation with business logic in the resolver makes both harder to test; run validation first, then business logic.
Validating email format in every resolver is repetitive, so create an Email scalar once. When resolvers return
different error shapes, clients don’t know which field is wrong. Standardize on one format. And don’t skip
validation on update mutations; validate optional fields too if they’re supplied.
Frequently Asked Questions
Should I use custom scalars or rule-based validators?
Use both. Custom scalars handle format validation at the schema level; rule-based validators handle business rules such as length, required fields, and ranges at the resolver level. They complement each other.
Can I use Zod for GraphQL validation?
Yes. Define a Zod schema for each input type and call schema.parse(input) at the start of each resolver. Zod
gives you both type inference and rich error paths. The trade-off is duplicating the schema definition once in
GraphQL types and once in Zod.
How do I handle nested input validation?
Report nested fields as flat paths in the error response. Use dot paths like address.street and address.city
so the client knows where the error belongs. Because the field property supports dot notation, clients can map
those values back to nested form fields.
What about file uploads?
GraphQL file uploads use the graphql-upload package or multipart requests. Check file size, MIME type, and
extension in the resolver before doing anything else. Custom scalars don’t work well for file inputs.
Related Resources
GraphQL Error Extension Pattern
Attach structured metadata to GraphQL errors using extension codes for predictable client-side error handling.
PatternGraphQL DataLoader Pattern
Coalesce individual load requests into batched calls with per-request caching to prevent N+1 queries and redundant fetches.
RecipeValidate and Sanitize GraphQL Input Types Server-Side
Implement centralized input validation in GraphQL using custom validation functions, Zod schemas, and input type transforms
PatternGraphQL Federated Entity Pattern
Share an entity across Apollo Federation subgraphs. Use @key, @external, and @shareable so each service owns the fields it knows best.
PatternBackend for Frontend (BFF) Pattern
Create dedicated backend services tailored to the specific needs of each frontend client type, aggregating downstream APIs and optimizing data shapes per platform.
PatternGraphQL Connection Pagination Pattern
Implement Relay-style cursor-based pagination with edges, nodes, and pageInfo for stable GraphQL list queries.