StackPractices
intermediate Por Mathias Paulenko

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

flowchart diagram: Cliente envía mutació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.