StackPractices
intermediate By Mathias Paulenko

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

flowchart diagram: Client sends mutation

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.