GraphQL Mutation Validation: Centralized Input Checks
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. One resolver
returns { message: "Email is required" }, another returns { code: "EMAIL_REQUIRED" }, and the frontend has
to handle both. Centralizing fixes that.
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. For returning those structured errors to clients, the GraphQL error extension pattern pairs well with this approach. If you’re batching reads alongside mutations, the DataLoader pattern handles caching and deduplication on the query side.
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. I’ve seen
teams skip this and end up with createUser checking email format one way and updateUser checking it another
way — the inconsistency bites hard when a frontend tries to render errors uniformly. If you’ve got a createUser,
updateUser, deleteUser, createPost, and updatePost mutation all checking email and name fields, that’s
five places to update when the email regex changes. With a centralized pipeline, you change it in one spot.
When to Avoid
Skip it for a single mutation with one or two simple fields — the overhead isn’t 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. I once added a full validation pipeline to a one-field webhook endpoint and regretted it:
the pipeline took longer to read than a plain if (!input.url) throw ... would have. Another case to skip: if
your mutations are all internal (service-to-service, no human filling a form), the structured error format buys
you nothing — the calling service can handle a simple error string just fine.
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. The scalar’s parseValue and parseLiteral
methods fire before anything else, so format errors never reach your resolver code. Think of it as a bouncer at
the door: if the email doesn’t match the regex, the request never gets in. You can also use scalars for URL,
Date, PhoneNumber, or any other format where the validation rule is purely syntactic — no database lookup
needed.
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. That matters for
form-heavy clients: users see all problems at once instead of fixing one field, resubmitting, and hitting the
next error. I’ve watched users rage-quit forms that only showed one error at a time — collecting everything up
front is a small change with a big UX payoff. The rule engine is also easy to unit test: pass a known input,
assert the error list, done. No database, no mocks, no async setup.
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. Business validation can’t run earlier because it needs context — the database state, the current user, or related records. If you skip the scalar and rule layers and go straight to business validation, you’re querying the database for every request, even the ones that fail on a missing required field. That’s a waste of a round trip. One more thing: the three layers aren’t just about catching errors — they’re about catching them at the right time. A scalar rejection is cheaper than a rule failure, which is cheaper than a database query. The earlier you catch the error, the less work the server does. That matters under load: if you’re handling 1000 mutations per second and 20% fail on a missing required field, catching that at the scalar layer saves 200 database round trips per second. The math is simple: if your database query takes 5ms and the scalar check takes 0.1ms, catching 200 invalid requests at the scalar layer saves a full second of database time per second of traffic. That’s not a micro-optimization — it’s a real capacity improvement that lets you handle more valid traffic with the same infrastructure.
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. If you’re already using Zod for your TypeScript types, you can share the schema definition between the GraphQL input type and the validation layer — that cuts the duplication in half.
A rule-based pipeline handles most cases. For a single mutation with two fields, a plain if check in the
resolver is simpler and reads better than wiring up a rule engine. I’ve seen teams reach for the full pipeline
on day one and then rip it out when they realize they’ve got one mutation with a name and email field.
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. Log validation failures with the timestamp and fields from the error
extension so you can spot patterns in what users get wrong. If you notice INVALID_EMAIL spiking on a specific
form, that’s a signal the UI isn’t making the email format clear enough.
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. Another one I’ve
seen: teams forget to sanitize after validation passes, so Alice@Example.com ends up in the database as-is
instead of alice@example.com. Trim and normalize before the INSERT, not after.
Summary
The GraphQL mutation validation pattern splits input checking into three layers: schema-level scalars catch
format errors early, rule-based validators collect all field-level errors in one pass, and business validation
handles database-dependent checks in the resolver. Reach for it when you’ve got three or more mutations like
createUser, updatePost, and deleteComment that share the same required, minLength, and email rules,
and your client renders form fields with per-field error messages. Keep error codes consistent across the API,
collect every error before throwing, and sanitize inputs after validation passes. Skip it for single-field
mutations where a plain if check does the job — the pipeline earns its keep when you’ve got five or more
mutations sharing the same rules.
See Also
The Apollo Server error handling docs cover
GraphQLError extensions and structured error responses in depth. The
GraphQL spec section on errors defines the standard error format.
For schema-level validation with TypeScript, Zod gives you type inference and composable
schemas that pair well with GraphQL input types. If you’re looking at pagination for validation result lists,
the GraphQL connection pagination pattern covers cursor-based
pagination that works well for large error sets. For federated setups where validation needs to span subgraphs,
the GraphQL federated entity pattern explains how entities
resolve across services.
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 like length, required fields, and ranges at the resolver level. They complement each other — scalars catch the obvious garbage, rules catch the rest.
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: you're duplicating the schema definition once
in GraphQL types and once in Zod. For small APIs that's fine; for large ones, consider generating one from the
other.
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 without extra parsing.
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 — you'll
want a dedicated upload handler instead.
How do I test validation rules?
Write unit tests that call validateInput with known inputs and assert the thrown GraphQLError extensions.
Test each rule in isolation first, then test combinations. For custom scalars, test parseValue and
parseLiteral separately. Mock the database for business validation tests so they stay fast and deterministic.
If a rule can fail, write a test for the failure case too — don't only test the happy path.
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.