Patrón GraphQL Mutation Validation: Validación de Inputs Centralizada
Centralizá la validación de mutaciones GraphQL con reglas reutilizables, scalars y errores estructurados. Ejemplos en TypeScript y mejores prácticas.
Descripción General
Las mutaciones GraphQL te pasan inputs que tenés que validar antes de la lógica de negocio. Sin un enfoque centralizado, cada resolver termina rehaciendo las mismas comprobaciones: campos requeridos, formato de email, longitud de strings, rangos numéricos. Esa duplicación produce reglas y formatos de error inconsistentes en toda la API.
El patrón GraphQL Mutation Validation canaliza la validación de inputs a través de un pipeline reutilizable. Cada mutación pasa su input por un conjunto de reglas y recibe una lista estructurada de errores a nivel de campo con códigos legibles por máquina. Así los resolvers pueden enfocarse en la lógica de negocio y dejar de preocuparse por los inputs.
Cuándo Usarlo
Usalo cuando tengas varias mutaciones que acepten input de usuario y las mismas reglas de validación aparezcan una y otra vez en distintos resolvers. También sirve cuando el cliente necesita detalles de error a nivel de campo para renderizar formularios, cuando querés códigos de error consistentes en toda la API y cuando querés separar la validación de la lógica de negocio.
Cuándo Evitarlo
Evitalo para una sola mutación con uno o dos campos simples. Un pipeline de validación agrega overhead que puede
no valer la pena. También evitalo si la base de datos o el esquema ya manejan toda la validación. Y no lo
introduzcas
a menos que el cliente pueda consumir errores estructurados a nivel de campo; para herramientas internas suele
bastar con un BAD_REQUEST genérico.
Solución
Framework de Validación
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(),
},
});
}
}
Reglas de Validación Reutilizables
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',
}),
};
Uso de Validadores en 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];
},
},
};
Validación a Nivel de Esquema con Scalars Personalizados
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!
}
`;
Manejo de Errores en el Cliente
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;
}
Explicación
El patrón organiza la validación en tres capas.
La validación a nivel de esquema usa scalars personalizados como Email para rechazar valores inválidos durante
el parseo, antes de que el resolver se ejecute. Esta es la primera barrera que frena inputs inválidos.
La validación basada en reglas invoca a validateInput sobre el objeto de input. Cada regla revisa un campo; si
falla, agrega un error. Se ejecutan todas las reglas, así el cliente recibe todos los errores en un solo
mensaje.
La validación de negocio cubre comprobaciones que necesitan la base de datos, como emails duplicados o la existencia de foreign keys. Estas corren en el resolver después de que pase la validación por reglas y suelen devolver un error de un solo campo ligado a una regla de negocio específica.
Todos los errores de validación comparten el mismo formato de extensiones con un code, un array fields y un
array detallado errors. Los clientes pueden mirar el código y decidir qué mensaje mostrar a nivel de campo.
Variantes
Los scalars personalizados actúan en el parseo del esquema. Funcionan bien para controles estrictos de formato, como email, URL o fecha. Los validadores basados en reglas entran en juego cuando arranca el resolver y se encargan de reglas a nivel de campo como requerido, longitud y rango. Las directivas de esquema sirven para autorización y rate limiting. Zod encaja en proyectos TypeScript que quieren inferencia de tipos, mientras que Joi o Yup sirven para objetos anidados complejos.
Un pipeline basado en reglas cubre la mayoría de los casos.
Mejores Prácticas
Validá temprano con scalars personalizados para que los valores inválidos no lleguen al resolver. Devolvé todos los
errores de validación juntos, así el cliente puede mostrar todos los problemas de campo a la vez. Usá
códigos de error consistentes como REQUIRED, MIN_LENGTH e INVALID_EMAIL en todas las mutaciones. Dejá los
cheques de formato en los validadores y los de negocio en los resolvers. Después de que la validación pase,
sanitizá los inputs — trim, minúsculas en emails, normalizar URLs — pero antes de escribir a la base de datos.
Errores Comunes
Lanzar un error en el primero obliga al cliente a un bucle de enviar-corregir-reenviar, así que recolectá todas las
fallas primero. Mezclar validación con lógica de negocio en el resolver hace ambas más difíciles de testear;
ejecutá la validación primero y la lógica de negocio después. Validar el formato de email en cada resolver es
repetitivo, así que creá un scalar Email una vez. Cuando los resolvers devuelven distintas formas de error, el
cliente no sabe a qué campo aplicar el error. Estandarizá en una sola forma para que no se pierda. Y no te saltes
la validación en
mutaciones de actualización; validá también los campos opcionales cuando están presentes.
Preguntas frecuentes
¿Debería usar scalars personalizados o validadores basados en reglas?
Usá ambos. Los scalars personalizados se encargan de la validación de formato a nivel de esquema; los validadores basados en reglas se encargan de las reglas de negocio como longitud, campos requeridos y rangos a nivel de resolver. Se complementan.
¿Puedo usar Zod para validación GraphQL?
Sí. Definí un esquema Zod para cada input type y llamá schema.parse(input) al inicio de cada resolver. Zod te da
inferencia de tipos y rutas de error detalladas. El costo es duplicar la definición del esquema, una en los tipos
GraphQL y otra en Zod.
¿Cómo manejo validación de inputs anidados?
Reportá los campos anidados como paths planos en el error. Por ejemplo, devolvé address.street y address.city
para mantener el path legible. La propiedad field entiende notación de punto, así los clientes pueden mapear esos
valores a campos de formulario anidados.
¿Qué pasa con file uploads?
Los file uploads de GraphQL usan el paquete graphql-upload o peticiones multipart. Revisá tamaño de archivo,
tipo MIME y extensión en el resolver antes de procesar. Los scalars personalizados no manejan bien los inputs de
archivo.
Recursos Relacionados
Patron de Extension de Errores en GraphQL
Adjunta metadatos estructurados a errores GraphQL usando codigos de extension para manejo de errores predecible del lado del cliente.
PatternPatron DataLoader en GraphQL
Consolida llamadas de carga individuales en llamadas batch con cache por peticion para prevenir queries N+1 y fetches redundantes.
RecipeValidacion y sanitizacion de input types en GraphQL en
Implementa validacion centralizada de inputs en GraphQL con funciones personalizadas, schemas Zod y transformaciones de input types
PatternPatrón de entidades federadas en GraphQL
Compartí una entidad entre subgraphs de Apollo Federation. Usá @key, @external y @shareable para que cada servicio maneje los campos que conoce.
PatternPatrón Backend for Frontend (BFF)
Crea servicios backend dedicados adaptados a las necesidades específicas de cada tipo de frontend client, agregando APIs downstream y optimizando formas de datos por plataforma.
PatternPatron GraphQL Connection Pagination
Implementa paginacion basada en cursores estilo Relay con edges, nodes y pageInfo para queries de listas estables en GraphQL.