GraphQL

Input Validation in GraphQL

20 min Lesson 12 of 35

Input Validation Strategies

Input validation is critical for data integrity and security in GraphQL APIs. GraphQL provides type-level validation automatically, but business logic validation must be implemented in resolvers.

Type-Level Validation

GraphQL automatically validates that input matches the schema types:

type Mutation { createUser(input: CreateUserInput!): User! } input CreateUserInput { username: String! email: String! age: Int role: UserRole! } enum UserRole { USER ADMIN MODERATOR } # GraphQL automatically validates: # - username is a string # - email is a string # - age is an integer (if provided) # - role is one of the enum values # - Required fields are present
Note: Type validation happens before your resolvers run. Invalid types return errors immediately without executing resolver logic.

Custom Scalar Validation

Create custom scalars for specific validation requirements:

const { GraphQLScalarType, GraphQLError } = require('graphql'); // Email scalar const EmailScalar = new GraphQLScalarType({ name: 'Email', description: 'Valid email address', serialize(value) { return value; // Send to client as string }, parseValue(value) { // Validate incoming value from variables if (typeof value !== 'string') { throw new GraphQLError('Email must be a string'); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { throw new GraphQLError('Invalid email format'); } return value.toLowerCase(); }, parseLiteral(ast) { // Validate inline values in query if (ast.kind !== 'StringValue') { throw new GraphQLError('Email must be a string'); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(ast.value)) { throw new GraphQLError('Invalid email format'); } return ast.value.toLowerCase(); } }); // URL scalar const URLScalar = new GraphQLScalarType({ name: 'URL', description: 'Valid URL', parseValue(value) { try { new URL(value); return value; } catch (error) { throw new GraphQLError('Invalid URL format'); } } }); // Use in schema const typeDefs = gql` scalar Email scalar URL input CreateUserInput { username: String! email: Email! website: URL } `; const resolvers = { Email: EmailScalar, URL: URLScalar };

Validation Libraries Integration

Use libraries like Joi, Yup, or validator.js for complex validation:

const Joi = require('joi'); const { ValidationError } = require('./errors/CustomErrors'); // Define validation schemas const createUserSchema = Joi.object({ username: Joi.string() .alphanum() .min(3) .max(30) .required() .messages({ 'string.alphanum': 'Username must contain only letters and numbers', 'string.min': 'Username must be at least 3 characters', 'string.max': 'Username cannot exceed 30 characters' }), email: Joi.string() .email() .required() .messages({ 'string.email': 'Please provide a valid email address' }), password: Joi.string() .min(8) .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .required() .messages({ 'string.min': 'Password must be at least 8 characters', 'string.pattern.base': 'Password must contain uppercase, lowercase, and number' }), age: Joi.number() .integer() .min(13) .max(120) .messages({ 'number.min': 'You must be at least 13 years old', 'number.max': 'Invalid age' }), bio: Joi.string() .max(500) .messages({ 'string.max': 'Bio cannot exceed 500 characters' }) }); // Validation helper function validateInput(schema, input) { const { error, value } = schema.validate(input, { abortEarly: false }); if (error) { const fields = {}; error.details.forEach(detail => { fields[detail.path[0]] = detail.message; }); throw new ValidationError('Validation failed', fields); } return value; } // Use in resolver const resolvers = { Mutation: { createUser: async (_, { input }) => { // Validate input const validatedInput = validateInput(createUserSchema, input); // Check for duplicates const existingUser = await User.findOne({ $or: [ { username: validatedInput.username }, { email: validatedInput.email } ] }); if (existingUser) { if (existingUser.username === validatedInput.username) { throw new ValidationError('Username already taken', { username: 'This username is already in use' }); } if (existingUser.email === validatedInput.email) { throw new ValidationError('Email already registered', { email: 'This email is already registered' }); } } // Create user return await User.create(validatedInput); } } };
Tip: Set abortEarly: false in Joi validation to collect all validation errors at once, providing better user experience.

Field-Level Validation

Validate individual fields with reusable validator functions:

// validators/fieldValidators.js const validators = { username: (value) => { if (!value || value.length < 3) { return 'Username must be at least 3 characters'; } if (!/^[a-zA-Z0-9_]+$/.test(value)) { return 'Username can only contain letters, numbers, and underscores'; } if (value.length > 30) { return 'Username cannot exceed 30 characters'; } return null; }, email: (value) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { return 'Invalid email format'; } return null; }, password: (value) => { if (value.length < 8) { return 'Password must be at least 8 characters'; } if (!/(?=.*[a-z])/.test(value)) { return 'Password must contain a lowercase letter'; } if (!/(?=.*[A-Z])/.test(value)) { return 'Password must contain an uppercase letter'; } if (!/(?=.*\d)/.test(value)) { return 'Password must contain a number'; } return null; }, phone: (value) => { const phoneRegex = /^\+?[1-9]\d{1,14}$/; if (!phoneRegex.test(value)) { return 'Invalid phone number format'; } return null; }, url: (value) => { try { new URL(value); return null; } catch { return 'Invalid URL format'; } } }; // Validate multiple fields function validateFields(input, fieldValidators) { const errors = {}; Object.keys(fieldValidators).forEach(field => { if (input[field] !== undefined) { const error = fieldValidators[field](input[field]); if (error) { errors[field] = error; } } }); if (Object.keys(errors).length > 0) { throw new ValidationError('Validation failed', errors); } } module.exports = { validators, validateFields };

Form Validation Patterns

const { validators, validateFields } = require('./validators/fieldValidators'); const resolvers = { Mutation: { updateProfile: async (_, { input }, context) => { if (!context.user) { throw new AuthenticationError('Authentication required'); } // Validate only provided fields const validationRules = {}; if (input.username) validationRules.username = validators.username; if (input.email) validationRules.email = validators.email; if (input.website) validationRules.url = validators.url; if (input.phone) validationRules.phone = validators.phone; validateFields(input, validationRules); // Additional business logic validation if (input.username) { const existing = await User.findOne({ username: input.username, _id: { $ne: context.user.id } }); if (existing) { throw new ValidationError('Username taken', { username: 'This username is already in use' }); } } // Update user return await User.findByIdAndUpdate( context.user.id, input, { new: true, runValidators: true } ); } } };
Warning: Always validate on the server side, even if client-side validation exists. Never trust client input.

Async Validation

Handle validation that requires database or external API calls:

const asyncValidators = { uniqueUsername: async (username) => { const exists = await User.findOne({ username }); if (exists) { throw new ValidationError('Username unavailable', { username: 'This username is already taken' }); } }, uniqueEmail: async (email) => { const exists = await User.findOne({ email }); if (exists) { throw new ValidationError('Email registered', { email: 'This email is already registered' }); } }, validCouponCode: async (code) => { const coupon = await Coupon.findOne({ code, isActive: true }); if (!coupon) { throw new ValidationError('Invalid coupon', { couponCode: 'This coupon code is invalid or expired' }); } if (coupon.usageCount >= coupon.maxUsage) { throw new ValidationError('Coupon limit reached', { couponCode: 'This coupon has reached its usage limit' }); } return coupon; } }; // Use in resolver const resolvers = { Mutation: { createUser: async (_, { input }) => { // Synchronous validation first validateFields(input, { username: validators.username, email: validators.email, password: validators.password }); // Async validation await asyncValidators.uniqueUsername(input.username); await asyncValidators.uniqueEmail(input.email); // If all validation passes, create user return await User.create(input); } } };
Exercise:
  1. Create a custom PhoneNumber scalar that validates international phone numbers
  2. Implement a Joi schema for a blog post with title (3-100 chars), content (required), tags (max 5), and category
  3. Create async validators for checking if a slug is unique and if a category exists
  4. Build a registration mutation with comprehensive validation including password strength, email verification, and username availability
  5. Implement field-level validation that returns all errors at once for better UX