GraphQL Mutation Validation: Validación 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. Un resolver devuelve { message: "Email is required" }, otro devuelve { code: "EMAIL_REQUIRED" },
y el frontend tiene que manejar ambos. Centralizar arregla eso.
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. Para devolver esos errores estructurados a los clientes, el patrón de error extension de GraphQL complementa bien este enfoque. Si estás batcheando lecturas junto con mutaciones, el patrón DataLoader maneja caching y deduplicación del lado de las queries.
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. Vi equipos que se saltearon esto y terminaron con createUser validando email
de una forma y updateUser de otra — la inconsistencia se nota fuerte cuando un frontend intenta renderizar
errores de forma uniforme. Si tenés createUser, updateUser, deleteUser, createPost y updatePost todos
chequeando campos email y name, son cinco lugares para actualizar cuando cambia la regex de email. Con un
pipeline centralizado, lo cambiás en un solo lugar.
Cuándo Evitarlo
Saltealo para una sola mutación con uno o dos campos simples — el overhead no vale 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. Una vez agregué un pipeline completo de validación a un endpoint de webhook
de un solo campo y me arrepentí: el pipeline tardaba más en leer que un simple if (!input.url) throw .... Otro
caso para saltearlo: si tus mutaciones son todas internas (servicio a servicio, sin humano llenando un
formulario), el formato de error estructurado no te compra nada — el servicio que llama puede manejar un string
de error simple sin problema.
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. Los métodos
parseValue y parseLiteral del scalar se ejecutan antes que cualquier otra cosa, así que los errores de formato
nunca llegan al código del resolver. Pensalo como un guardia en la puerta: si el email no matchea la regex, la
petición no entra. También podés usar scalars para URL, Date, PhoneNumber, o cualquier otro formato donde
la regla de validación es puramente sintáctica — sin lookup en la base de datos.
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. Eso importa para clientes con formularios pesados: los usuarios ven todos los problemas de una vez en
lugar de arreglar un campo, reenviar y toparse con el siguiente error. Vi usuarios abandonar formularios que
solo mostraban un error a la vez — recolectar todo upfront es un cambio chico con un impacto grande en UX. El
motor de reglas también es fácil de testear unitariamente: pasá un input conocido, verificá la lista de errores,
listo. Sin base de datos, sin mocks, sin setup async.
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. La validación de negocio no puede correr antes porque necesita contexto — el estado de la base de datos, el usuario actual, o registros relacionados. Si te salteas las capas de scalars y reglas y vas directo a la validación de negocio, estás consultando la base de datos para cada petición, incluso las que fallan por un campo requerido. Es desperdiciar un round trip. Una cosa más: las tres capas no son solo para atrapar errores — son para atraparlos en el momento correcto. Un rechazo de scalar es más barato que un fallo de regla, que es más barato que una consulta a la base de datos. Cuanto antes atrapes el error, menos trabajo hace el servidor. Eso importa bajo carga: si estás manejando 1000 mutaciones por segundo y el 20% fallan por un campo requerido, atrapar eso en la capa de scalar ahorra 200 round trips a la base de datos por segundo.
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. Si ya estás usando Zod para tus tipos de TypeScript, podés compartir la definición del schema entre el input type de GraphQL y la capa de validación — eso corta la duplicación a la mitad.
Un pipeline basado en reglas cubre la mayoría de los casos. Para una sola mutación con dos campos, un if plano
en el resolver es más simple y se lee mejor que armar un motor de reglas. Vi equipos que arrancaban con el
pipeline completo el día uno y después lo sacaban cuando se daban cuenta de que tenían una sola mutación con
un campo name y un campo email.
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.
Logueá las fallas de validación con el timestamp y fields de la extensión del error para detectar patrones en
lo que los usuarios hacen mal. Si notás que INVALID_EMAIL sube en un formulario específico, es una señal de que
la UI no está dejando claro el formato del email.
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. Otro
que vi: equipos que se olvidan de sanitizar después de que pasa la validación, así que Alice@Example.com termina
en la base de datos tal cual en vez de alice@example.com. Trim y normalizar antes del INSERT, no después.
Resumen
El patrón GraphQL Mutation Validation separa la validación de inputs en tres capas: los scalars a nivel de
esquema capturan errores de formato temprano, los validadores basados en reglas recolectan todos los errores de
campo en un solo pasaje, y la validación de negocio maneja comprobaciones que dependen de la base de datos en el
resolver. Usalo cuando tengas múltiples mutaciones que comparten las mismas reglas y los clientes necesitan
detalles de error a nivel de campo. Mantené los códigos de error consistentes en toda la API, recolectá todos
los errores antes de lanzar, y sanitizá los inputs después de que pase la validación. Saltealo para mutaciones
de un solo campo donde un if simple hace el trabajo — el pipeline vale la pena cuando tenés cinco o más
mutaciones que comparten las mismas reglas.
See Also
La documentación de Apollo Server sobre errores
cubre GraphQLError extensions y respuestas de error estructuradas en profundidad. La
sección de errores de la spec de GraphQL define el formato estándar
de errores. Para validación a nivel de esquema con TypeScript, Zod te da inferencia de tipos
y schemas composables que combinan bien con los input types de GraphQL. Si estás mirando paginación para listas
de resultados de validación, el
patrón de connection pagination de GraphQL cubre
paginación basada en cursores que funciona bien para conjuntos grandes de errores. Para setups federados donde
la validación necesita abarcar subgrafos, el
patrón de federated entity de GraphQL explica cómo las
entidades se resuelven entre servicios.
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 — los scalars frenan la basura obvia, las reglas atrapan el resto.
¿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: estás duplicando la definición del esquema, una en los
tipos GraphQL y otra en Zod. Para APIs chicas está bien; para grandes, considerá generar una desde la otra.
¿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 sin parsing extra.
¿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 — vas a querer un handler de upload dedicado.
¿Cómo testeo las reglas de validación?
Escribí tests unitarios que llamen a validateInput con inputs conocidos y verifiquen las extensiones del
GraphQLError lanzado. Testeá cada regla de forma aislada primero, después testeá combinaciones. Para scalars
personalizados, testeá parseValue y parseLiteral por separado. Hacé mock de la base de datos para los tests
de validación de negocio así quedan rápidos y determinísticos. Regla práctica: si una regla puede fallar, debería
tener un test que pruebe que falla correctamente.
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.