En aplicaciones web del lado del cliente (Next.js Client Components, SPAs, microfrontends), el peso del bundle de JavaScript incide directamente en métricas clave de Core Web Vitals: en particular Total Blocking Time (TBT) e Interaction to Next Paint (INP). Cuanto mayor sea el script transferido, más tiempo invertirá la CPU del dispositivo móvil parseando y compilando código en el motor V8, bloqueando el hilo principal del navegador.
El referente histórico del ecosistema —Zod— se estructuró sobre un modelo orientado a objetos con sintaxis encadenada (Fluent Interface):
z.string().min(5).max(100).email().trim();Bajo este diseño, todos los métodos de validación y transformación posibles residen de forma fija en el prototipo de la clase ZodString y de su clase base ZodType. Los algoritmos de eliminación de código muerto (Tree-Shaking) de los empaquetadores modernos (Webpack, Rollup, Turbopack, Vite) analizan el grafo de importaciones estáticas a nivel de módulos ES. Al estar los métodos vinculados a un objeto monolítico, el empaquetador no puede dilucidar qué funciones se utilizan y cuáles no. En consecuencia, importar una única primitiva como z.string() arrastra la librería Zod íntegra (unos 55–70 KB sin comprimir / ~14 KB gzip) al bundle del cliente.
Valibot resuelve este problema mediante una descomposición funcional pura. En Valibot no existen clases monolíticas. Cada esquema primitivo (string, number), cada validador (email, minLength, regex) y cada transformación (trim, toLowerCase) es una función de orden superior aislada e independientemente exportada.
El elemento articulador es el pipeline pipe(), que compone un esquema base con una secuencia de acciones de validación y transformación (Actions). El empaquetador incluye en el bundle final únicamente las funciones importadas de forma explícita, logrando reducir el código de validación de un formulario a menos de 1 KB (gzip) (un ahorro de tamaño del 90–95%).
La diferencia entre el encadenamiento de métodos OOP y la composición funcional ilustra cómo actúa el Tree-Shaking:
+-------------------------------------------------------------------------+
| GRAFO MONOLÍTICO DE ZOD (Tree-Shaking Imposible) |
+-------------------------------------------------------------------------+
| import { z } from 'zod'; |
| z.string().email(); |
| |
| El empaquetador inspecciona la clase ZodString: |
| class ZodString { |
| min() { ... } // <- ¡Se incluye en el bundle aunque no se use! |
| max() { ... } // <- ¡Se incluye en el bundle! |
| uuid() { ... } // <- ¡Se incluye en el bundle! |
| regex() { ... } // <- ¡Se incluye en el bundle! |
| ip() { ... } // <- ¡Se incluye en el bundle! |
| } |
| => Tamaño final del bundle: ~14 KB (gzip) |
+-------------------------------------------------------------------------+
| |
| GRAFO MODULAR DE VALIBOT (Tree-Shaking Total) |
+-------------------------------------------------------------------------+
| import { object, string, email, pipe } from 'valibot'; |
| |
| El empaquetador evalúa las exportaciones ES: |
| - object() => Incluido en el bundle (~350 bytes) |
| - string() => Incluido en el bundle (~150 bytes) |
| - email() => Incluido en el bundle (~200 bytes) |
| - pipe() => Incluido en el bundle (~100 bytes) |
| * ¡Las restantes 150+ funciones de la librería se descartan del build! |
| => Tamaño final del bundle: ~850 bytes (gzip)! |
+-------------------------------------------------------------------------+El primer argumento de pipe() valida el tipo primitivo raíz (string, number, object). Ante discordancias, el flujo se detiene de inmediato.
Los datos avanzan a través de transformaciones (trim, toLowerCase) y comprobaciones de restricciones (minLength, email).
Al encontrar fallos, las acciones crean objetos Issue con especificación estricta de rutas (path) y mensajes claros.
safeParse devuelve una unión discriminada: { success: true, output } o bien { success: false, issues }.
Veamos la implementación de la validación del perfil de un usuario:
import * as v from 'valibot';
// Definición de esquema mediante composición funcional con pipe()
export const UserProfileSchema = v.object({
id: v.pipe(
v.string(),
v.uuid('El identificador debe ser un UUID v4 válido')
),
username: v.pipe(
v.string(),
v.trim(), // Transformación: remueve espacios superfluos
v.minLength(3, 'El nombre debe contener al menos 3 caracteres'),
v.maxLength(20, 'El nombre no puede exceder 20 caracteres')
),
email: v.pipe(
v.string(),
v.trim(),
v.toLowerCase(), // Transformación: normalización de mayúsculas
v.email('Formato de correo electrónico no válido')
),
age: v.pipe(
v.number(),
v.minValue(18, 'El servicio está restringido a mayores de 18 años'),
v.maxValue(100, 'Introduce una edad válida')
),
website: v.optional(
v.pipe(
v.string(),
v.url('Formato de URL no válido')
)
),
});
// Inferencia estática de los datos de entrada en bruto (previos a transformaciones)
export type UserProfileInput = v.InferInput<typeof UserProfileSchema>;
// Inferencia estática de los datos procesados (posteriores a trim y toLowerCase)
export type UserProfileOutput = v.InferOutput<typeof UserProfileSchema>;[!NOTE] Aprecia la segregación explícita entre
InferInputeInferOutput. Al contener transformaciones (trim(),toLowerCase()), la estructura de los datos iniciales puede diferir de la salida procesada, lo cual TypeScript rastrea con absoluta seguridad estática.
Valibot permite crear validaciones a medida sin inflar el paquete con check o el método de bajo nivel rawCheck:
import * as v from 'valibot';
// Acción personalizada para validar fortaleza de contraseña
export function passwordStrength(requirementMessage = 'La contraseña debe contener al menos 1 número y 1 carácter especial') {
return v.check((input: string) => {
const hasNumber = /\d/.test(input);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(input);
return hasNumber && hasSpecialChar;
}, requirementMessage);
}
// Acción personalizada para validar NIF/CIF o identificador fiscal
export function isValidTaxId(errorMessage = 'Identificador fiscal de empresa no válido') {
return v.check((input: string) => {
if (!/^\d{10}$/.test(input)) return false;
const coefficients = [2, 4, 10, 3, 5, 9, 4, 6, 8];
const controlSum = coefficients.reduce(
(sum, coeff, idx) => sum + coeff * Number(input[idx]),
0
);
const controlDigit = (controlSum % 11) % 10;
return Number(input[9]) === controlDigit;
}, errorMessage);
}
// Empleo de acciones personalizadas dentro de un pipeline
export const SecurityFormSchema = v.object({
taxId: v.pipe(
v.string(),
v.trim(),
isValidTaxId()
),
password: v.pipe(
v.string(),
v.minLength(8, 'La contraseña debe tener un mínimo de 8 caracteres'),
passwordStrength()
),
});En formularios de cliente, se debe recurrir siempre a safeParse para evitar excepciones no controladas:
import * as v from 'valibot';
import { UserProfileSchema, UserProfileOutput } from './profile-schema';
export interface FormSubmissionResult {
isSuccess: boolean;
data?: UserProfileOutput;
fieldErrors?: Record<string, string>;
}
export function handleProfileSubmission(rawFormData: unknown): FormSubmissionResult {
// safeParse no arroja excepciones y devuelve una unión discriminada tipada
const result = v.safeParse(UserProfileSchema, rawFormData);
if (!result.success) {
// Transformar el array plano de issues en un mapa de errores por campo
const fieldErrors: Record<string, string> = {};
for (const issue of result.issues) {
// Extraer el nombre del primer segmento de la ruta (ej. 'email' o 'username')
const fieldName = issue.path?.[0]?.key;
if (typeof fieldName === 'string' && !fieldErrors[fieldName]) {
fieldErrors[fieldName] = issue.message;
}
}
return {
isSuccess: false,
fieldErrors,
};
}
// Los datos han sido completamente saneados y validados
return {
isSuccess: true,
data: result.output,
};
}A continuación se muestra un módulo reutilizable con validadores para procesos de autenticación en cliente:
import * as v from 'valibot';
// 1. Bloques primitivos reutilizables
export const EmailSchema = v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.email('Sintaxis de correo corporativo no válida'),
v.maxLength(100, 'El correo no debe superar los 100 caracteres')
);
export const PasswordSchema = v.pipe(
v.string(),
v.minLength(8, 'La contraseña debe tener al menos 8 caracteres'),
v.maxLength(64, 'La contraseña no puede exceder 64 caracteres')
);
// 2. Esquema de inicio de sesión
export const LoginSchema = v.object({
email: EmailSchema,
password: PasswordSchema,
rememberMe: v.optional(v.boolean(), false),
});
// 3. Esquema de registro con validación cruzada mediante forward/check
export const RegistrationSchema = v.pipe(
v.object({
email: EmailSchema,
password: PasswordSchema,
confirmPassword: v.string(),
agreeToTerms: v.pipe(
v.boolean(),
v.check((val) => val === true, 'Es obligatorio aceptar los términos y condiciones')
),
}),
// Validación entre campos a nivel de objeto
v.forward(
v.check(
(input) => input.password === input.confirmPassword,
'Las contraseñas introducidas no coinciden'
),
['confirmPassword'] // Asocia el error al campo confirmPassword
)
);
export type LoginCredentials = v.InferOutput<typeof LoginSchema>;
export type RegistrationPayload = v.InferOutput<typeof RegistrationSchema>;Los ingenieros acostumbrados a Zod tienden a escribir v.string().min(5) o v.object().required(). Esto arroja un fallo TypeError: v.string(...).min is not a function, dado que v.string() devuelve un descriptor básico de esquema sin métodos en su prototipo. Toda validación debe envolverse en v.pipe().
// ❌ INCORRECTO: intento de invocación orientada a objetos
const BrokenSchema = v.string().minLength(5); // ¡TypeError!
// ✅ RECOMENDADO: composición funcional mediante pipe()
const CorrectSchema = v.pipe(v.string(), v.minLength(5));La función pipe() procesa sus acciones estrictamente de izquierda a derecha. Si se antepone email() a la transformación trim(), una cadena como " [email protected] " será rechazada por el validador a causa de los espacios antes de que trim() actúe. Las labores de saneamiento deben anteceder siempre a las comprobaciones de formato.
// ❌ INCORRECTO: email() falla por espacios antes de que actúe trim()
const BadOrder = v.pipe(v.string(), v.email(), v.trim());
// ✅ RECOMENDADO: primero sanear los datos, luego validarlos
const GoodOrder = v.pipe(v.string(), v.trim(), v.email());El método v.parse() lanza una excepción ValiError ante cualquier inconformidad. Usarlo en controladores de formularios sin envolverlo en try/catch quiebra el árbol de componentes mostrando pantallas de error inesperadas. En interfaces de usuario cliente, utiliza siempre v.safeParse().
Aunque import * as v from 'valibot' se somete a Tree-Shaking en empaquetadores bien configurados, importar utilidades accesorias en archivos cliente puede introducir polyfills no deseados. Se recomienda recurrir a importaciones nombradas: import { object, string, pipe } from 'valibot'.
Verifica el tipo primitivo principal (string, number, boolean). Ante cualquier fallo, la ejecución se interrumpe.
Los datos entran a la primera acción (ej. normalización con trim()), la cual entrega el valor modificado.
El valor saneado pasa por los predicados (minLength, email). Los errores se añaden a la colección de issues.
Si no existen issues, safeParse devuelve un estado exitoso con el payload transformado output.