GraphQL
Input Validation in GraphQL
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:
- Create a custom
PhoneNumberscalar that validates international phone numbers - Implement a Joi schema for a blog post with title (3-100 chars), content (required), tags (max 5), and category
- Create async validators for checking if a slug is unique and if a category exists
- Build a registration mutation with comprehensive validation including password strength, email verification, and username availability
- Implement field-level validation that returns all errors at once for better UX