Tu aplicación espera que una API devuelva un nombre como texto. El editor permite llamar a nombre.toUpperCase(), el proyecto compila y las pruebas con datos habituales pasan. Sin embargo, una respuesta trae nombre: 42 y la pantalla falla al intentar convertirlo a mayúsculas.
¿Cómo puede ocurrir si escribiste una interfaz para esa respuesta? Porque describir el dato esperado y comprobar el dato recibido son operaciones diferentes. TypeScript ayuda con la primera; necesitamos código que se ejecute para la segunda.
Vamos a reproducir esa diferencia y después construir un pequeño cliente de API con Zod. El objetivo es reconocer dónde termina la confianza que proporciona el compilador y cómo establecer un contrato verificable antes de usar datos externos.
Qué comprueba TypeScript y qué llega a ejecutarse
El tipado estático analiza el código antes de ejecutarlo. Puede detectar que intentas sumar un número y un objeto, acceder a una propiedad inexistente o pasar un argumento incompatible con una función. Es parte de lo que hace útil incorporar TypeScript a un proyecto.
Las interfaces, los alias de tipo y las anotaciones no se convierten automáticamente en comprobaciones sobre los valores recibidos. El JavaScript resultante no conserva una interfaz que inspeccione una respuesta HTTP. La documentación de TypeScript sobre tipos eliminados durante la compilación explica esa separación.
La validación en runtime, o tiempo de ejecución, sí trabaja sobre valores concretos: pregunta si un campo existe, qué tipo tiene y si cumple determinadas reglas mientras el programa está funcionando.
| Mecanismo | Qué examina | Qué aporta |
|---|---|---|
| Tipado estático | Código y tipos que conoce el compilador | Detectar usos incompatibles antes de ejecutar |
| Validación en runtime | Valores que efectivamente recibe el programa | Aceptar o rechazar datos según reglas ejecutables |
Ambos se complementan. El problema aparece cuando damos al compilador una promesa sobre datos que nadie comprobó.
Una interfaz que compila y falla al ejecutarse
Este es un contraejemplo deliberado, no una solución. Guárdalo como sin-validar.ts; lo ejecutaremos después de preparar el entorno:
interface Usuario {
id: number;
nombre: string;
}
const usuario: Usuario = JSON.parse('{"id":"uno","nombre":42}');
console.log(typeof usuario.nombre); // "number", aunque TypeScript espera string.
console.log(usuario.nombre.toUpperCase()); // TypeError en ejecución.JSON.parse comprueba que el texto tenga sintaxis JSON válida. No conoce nuestro contrato Usuario. Su declaración de tipos permite un resultado any, que se puede asignar a esa interfaz sin demostrar que tenga su estructura.
Cambiar interface Usuario por type Usuario = ... no arregla el problema. Tampoco lo hace escribir JSON.parse(texto) as Usuario: una aserción comunica al compilador cómo tratar un valor, pero no lo transforma ni lo valida. TypeScript lo advierte expresamente en su documentación de aserciones de tipo.
Elegir entre type e interface sigue siendo una decisión útil para modelar código; no es una decisión sobre validación de entradas.
Por qué conviene recibir datos como unknown
Con unknown declaramos que todavía no conocemos la estructura del valor. TypeScript exige acotarla antes de utilizar sus propiedades. Esto obliga a tratar la incertidumbre, pero la anotación unknown tampoco valida por sí misma: la comprobación debe existir.
Podrías escribir condiciones con typeof, comprobar objetos y recorrer campos manualmente. TypeScript utiliza esas comprobaciones para estrechar tipos mediante narrowing. Cuando aparecen objetos anidados y varias reglas, un esquema ayuda a mantener juntas la validación y la descripción del dato aceptado.
Confiar en una API no garantiza que su respuesta tenga la forma esperada
Un proveedor puede cambiar un campo, devolver null donde antes había texto o responder con un error en una estructura distinta. También puedes haber entendido mal su contrato. Un código HTTP 200 confirma el resultado HTTP, no la compatibilidad con tu modelo.
Un cliente con una firma como get<Usuario>() solo ofrece validación real si su implementación ejecuta un validador. El parámetro genérico, por sí solo, no inspecciona el cuerpo de la respuesta.
Los fallos tampoco siempre producen una excepción inmediata. Un precio que llega como texto puede concatenarse en lugar de sumarse; un campo ausente puede terminar convertido en un valor por defecto incorrecto. Conviene detectar el incumplimiento cerca de la entrada, donde aún sabemos qué proveedor y operación lo originaron.
El recorrido que queremos construir es este:
No se pudo mostrar el diagrama. Puedes consultar su definición.
flowchart TD
A[Respuesta HTTP de la API] --> B{Estado y formato esperados}
B -->|No| E[Gestionar fallo de transporte o formato]
B -->|Sí| C[Leer JSON como unknown]
C --> D{Validar con el esquema}
D -->|No| F[Gestionar incumplimiento del contrato]
D -->|Sí| G[Usar el resultado validado y tipado]
Qué aporta Zod
Zod permite expresar un esquema mediante código y ejecutarlo sobre datos. Combina validación con inferencia estática: podemos obtener un tipo TypeScript a partir del mismo esquema, sin mantener una interfaz equivalente a mano.
Incluye primitivas, objetos anidados, listas, uniones, restricciones y transformaciones. Funciona tanto en Node.js como en navegadores. En este artículo usaremos Zod 4; su documentación pide TypeScript 5.5 o superior y strict habilitado.
No sustituye al compilador. Añade comprobaciones en ejecución donde el sistema recibe información cuyo contrato aún no ha verificado.
Preparar un entorno reproducible
Necesitas Node.js 22 o superior y pnpm. Crea una carpeta independiente del proyecto donde trabajas y añade este package.json:
{
"name": "validacion-zod-lab",
"private": true,
"type": "module"
}Instala Zod y el compilador:
pnpm add zod@4
pnpm add -D typescript@6 @types/node@22Usaremos tsc para compilar y comprobar tipos, y Node.js para ejecutar el JavaScript generado. Conserva el pnpm-lock.yaml del laboratorio para repetir las mismas versiones.
Crea tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": [
"node"
],
"outDir": "dist",
"noEmitOnError": true
},
"include": [
"*.ts"
]
}Los archivos .ts irán en esa misma carpeta. Los imports locales terminan en .js porque apuntan al JavaScript que emitirá el compilador con esta configuración.
Ahora puedes compilar el contraejemplo y ejecutarlo:
pnpm exec tsc
node dist/sin-validar.jsCompila correctamente, imprime number y después falla con un TypeError: toUpperCase no es una función del número recibido. Ese fallo es el resultado esperado del ejercicio.
Definir el contrato que realmente consumimos
Consultaremos usuarios de JSONPlaceholder, una API pública de datos ficticios para ejemplos. Solo necesitamos identificador, nombre, correo y ciudad. No hace falta declarar campos que nuestra aplicación no utilizará.
Guarda este esquema en usuario.ts:
import { z } from 'zod';
export const UsuarioApiSchema = z.object({
id: z.int().positive(),
name: z.string().trim().min(1).max(100),
email: z.email(),
address: z.object({
city: z.string().trim().min(1).max(100),
}),
});
export type UsuarioApi = z.infer<typeof UsuarioApiSchema>;El esquema exige un identificador entero positivo, un nombre no vacío después de quitar espacios exteriores, un correo con formato válido y una ciudad. Los límites de 100 caracteres son decisiones de este ejemplo: no afirmamos que sean límites oficiales del proveedor.
z.infer<typeof UsuarioApiSchema> obtiene el tipo de salida del esquema. Así, cambiar el contrato cambia también el tipo utilizado por nuestro código. La inferencia ocurre durante el análisis estático; solo llamar al método de validación comprueba una entrada real. Véase Zod: inferencia de tipos.
Qué ocurre con campos adicionales o ausentes
z.object elimina del resultado las propiedades no declaradas. z.strictObject las rechaza y z.looseObject las conserva. En este cliente elegimos tolerar campos nuevos del proveedor y trabajar solo con los seleccionados. Si la política exige rechazar propiedades desconocidas, conviene un objeto estricto. Zod: objetos.
Una propiedad es obligatoria por defecto. .optional() permite su ausencia o undefined; .nullable() permite null, que no significa lo mismo. Define esa diferencia según el contrato real, sin volver opcional todo para conseguir que la validación pase.
parse y safeParse: mismas reglas, distinta gestión del resultado
Los dos métodos aplican el esquema. La diferencia principal es cómo comunican un fallo de validación:
| Método | Si el dato cumple | Si el dato no cumple |
|---|---|---|
parse | Devuelve el resultado tipado | Lanza un ZodError |
safeParse | Devuelve success: true y data | Devuelve success: false y error |
parse encaja cuando ya tienes una frontera de manejo de excepciones. safeParse permite tratar la entrada inválida como una rama explícita. Ninguno valida «más» que el otro. Zod: uso básico.
Guarda validar.ts:
import { z } from 'zod';
import { UsuarioApiSchema } from './usuario.js';
const entrada: unknown = {
id: 1,
name: ' Ana ',
email: 'ana@example.com',
address: { city: 'Chillán', zipcode: '0000000' },
campoNuevo: true,
};
const usuario = UsuarioApiSchema.parse(entrada);
console.log(usuario.name); // Ana
console.log(usuario.address); // { city: 'Chillán' }
const resultado = UsuarioApiSchema.safeParse({
id: '1',
name: 'Ana',
email: 'no-es-un-correo',
address: { city: null },
});
if (!resultado.success) {
for (const issue of resultado.error.issues) {
const ruta = issue.path.map(String).join('.') || '(raíz)';
console.log(`${ruta}: ${issue.code}`);
}
} else {
console.log(resultado.data.email);
}
try {
UsuarioApiSchema.parse({});
} catch (error: unknown) {
if (!(error instanceof z.ZodError)) throw error;
console.log('Campos inválidos:', error.issues.length);
}Compila y ejecútalo:
pnpm exec tsc
node dist/validar.jsEl nombre queda normalizado como Ana; la dirección contiene solo la ciudad. En el segundo objeto se rechazan el identificador textual, el correo inválido y la ciudad nula. Usa usuario o resultado.data para continuar: volver a usar entrada ignoraría el resultado del procesamiento.
Cómo aprovechar los errores sin exponer toda la entrada
Cada elemento de error.issues identifica la ruta, el código y el mensaje del problema. Una ruta como address.city permite ubicar el campo anidado. Para formularios planos, Zod ofrece z.flattenError; para estructuras anidadas, z.treeifyError. La documentación de formato de errores describe ambas representaciones.
En una aplicación, traduce los códigos a mensajes comprensibles y evita usar el texto exacto de un mensaje interno como contrato estable. Tampoco registres automáticamente el cuerpo completo de una respuesta que pueda contener información personal. Nuestro ejemplo selecciona ruta y código para el diagnóstico.
safeParse gestiona fallos de validación; no es un capturador universal de excepciones. Si escribes una transformación o un refinamiento que lanza una excepción, Zod no la convierte automáticamente en un resultado inválido. Si esas funciones son asíncronas, usa parseAsync o safeParseAsync. Zod: refinamientos.
Validar, transformar y convertir no son lo mismo
Una transformación puede cambiar el tipo de salida. z.infer corresponde a z.output; para describir la entrada de un esquema se usa z.input. Guarda este ejemplo independiente como transformar.ts:
import { z } from 'zod';
const LongitudSchema = z.string().transform(texto => texto.length);
type Entrada = z.input<typeof LongitudSchema>; // string
type Salida = z.infer<typeof LongitudSchema>; // number; igual que z.output
const texto: Entrada = 'hola';
const longitud: Salida = LongitudSchema.parse(texto);
console.log(longitud); // 4
console.log(z.number().safeParse('42').success); // false
console.log(z.coerce.number().parse('42')); // 42
console.log(z.coerce.boolean().parse('false')); // true: Boolean('false')Ejecuta pnpm exec tsc y node dist/transformar.js. La primera operación devuelve la longitud de un texto; la conversión numérica es explícita. Observa la última línea: el texto 'false' no está vacío y Boolean('false') produce true. Zod: coerción.
En respuestas de APIs, convertir automáticamente valores puede ocultar una ruptura del contrato. Si el proveedor prometió un número, aceptar cualquier texto convertible merece una decisión consciente. El esquema de usuarios no convierte '1' en 1.
Consumir una API externa con una frontera de validación
Ahora conectaremos el esquema con una petición real. Separaremos la operación HTTP del programa que muestra el resultado, para poder probarla sin depender de la disponibilidad del proveedor.
Guarda api.ts:
import { z } from 'zod';
import { UsuarioApiSchema, type UsuarioApi } from './usuario.js';
export class ErrorDeContrato extends Error {
constructor(public readonly problemas: { ruta: string; codigo: string }[]) {
super('La API devolvió datos que no cumplen el contrato');
this.name = 'ErrorDeContrato';
}
}
export async function obtenerUsuario(id: number): Promise<UsuarioApi> {
const idValido = z.int().positive().parse(id);
const respuesta = await fetch(`https://jsonplaceholder.typicode.com/users/${idValido}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!respuesta.ok) {
await respuesta.body?.cancel();
throw new Error(`La API respondió con HTTP ${respuesta.status}`);
}
const mediaType = respuesta.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
if (mediaType !== 'application/json') {
await respuesta.body?.cancel();
throw new Error('La API no devolvió application/json');
}
let entrada: unknown;
try {
entrada = await respuesta.json();
} catch {
throw new Error('No se pudo leer la respuesta como JSON');
}
const resultado = UsuarioApiSchema.safeParse(entrada);
if (!resultado.success) {
throw new ErrorDeContrato(resultado.error.issues.map(issue => ({
ruta: issue.path.map(String).join('.') || '(raíz)',
codigo: issue.code,
})));
}
if (resultado.data.id !== idValido) {
throw new Error('La API devolvió un usuario distinto del solicitado');
}
return resultado.data;
}Hay varias comprobaciones con responsabilidades diferentes:
- El identificador de entrada. Una anotación
numberno expresa que deba ser un entero positivo; esa regla se ejecuta antes de construir la URL. - La petición. El límite de cinco segundos evita esperar indefinidamente en este ejemplo. Node.js proporciona
fetchyAbortSignal.timeout. - HTTP y representación. Se comprueba el estado, el tipo de contenido esperado por este cliente y que el cuerpo pueda leerse como JSON.
- El contrato. La entrada sigue siendo
unknownhasta validar su estructura. Los problemas se convierten en un error específico de integración. - La correspondencia. Un usuario válido puede no ser el solicitado; por eso se compara su identificador después de validar.
Comprobar Content-Type no prueba que el cuerpo sea correcto: también se procesa y valida. El mensaje «No se pudo leer la respuesta como JSON» cubre tanto sintaxis inválida como fallos durante la lectura del cuerpo, por ejemplo una interrupción.
El cliente devuelve el resultado procesado. Su Promise<UsuarioApi> expresa lo que produce después de comprobar los datos; no reemplaza ninguna de esas comprobaciones.
Guarda el programa de entrada como consultar.ts:
import { ErrorDeContrato, obtenerUsuario } from './api.js';
try {
const usuario = await obtenerUsuario(1);
console.log(`Usuario ${usuario.id}: ${usuario.name}`);
console.log(`Ciudad: ${usuario.address.city}`);
} catch (error: unknown) {
if (error instanceof ErrorDeContrato) {
console.error(error.message, error.problemas);
} else {
console.error('No se pudo completar la consulta; revisa la conexión y la respuesta HTTP.');
}
process.exitCode = 1;
}Compila y ejecuta:
pnpm exec tsc
node dist/consultar.jsCuando el servicio esté disponible y la respuesta cumpla el contrato, verás el identificador, nombre y ciudad del usuario 1. El programa distingue un incumplimiento del esquema de otros fallos; no muestra el correo ni imprime el cuerpo recibido. Las promesas y await permiten esperar la respuesta, pero no validan su contenido: si necesitas repasarlas, consulta cómo funcionan las promesas en JavaScript.
Para comprobar rechazos de forma reproducible, empieza por los datos locales de validar.ts. En pruebas del cliente, sustituye la respuesta HTTP por casos controlados: un 503, HTML en lugar de JSON, JSON mal formado y un objeto con id textual. No alteres la API pública ni dependas de que falle para probar tus ramas de error.
Para limpiar el laboratorio, elimina su carpeta cuando ya no la necesites. Los ejemplos solo realizan una consulta de lectura y generan JavaScript en dist/ al compilar.
Lo que un esquema no puede prometer
Una validación correcta significa que el valor cumplió las reglas que definiste en ese momento. No demuestra que el correo pertenezca al usuario, que un registro exista en tu base de datos ni que la persona tenga autorización para acceder a él. Tampoco convierte el texto aceptado en HTML seguro para insertarlo sin escapar.
Si estás recibiendo solicitudes en un backend, la validación es uno de los controles necesarios para proteger una API REST. El cliente no reemplaza la validación del servidor.
Considera también estos límites del ejemplo:
respuesta.json()carga el cuerpo antes de validar. El esquema no limita los bytes descargados; para proveedores o entradas de riesgo necesitas límites de tamaño en la capa HTTP correspondiente.- Un timeout limita la espera, no resuelve reintentos, cuotas ni indisponibilidad. Define esas políticas según la operación; no reintentes cualquier error sin criterio.
- Validar en una frontera no protege contra una modificación posterior del objeto. Evita mutaciones que invaliden las garantías de las que depende el resto del programa.
- Un esquema demasiado permisivo puede aceptar datos inútiles; uno demasiado estricto puede rechazar cambios compatibles. Esa decisión pertenece al contrato de tu aplicación.
Cuándo elegir Zod y qué alternativas considerar
Zod resulta conveniente cuando trabajas principalmente en TypeScript, quieres derivar tipos de esquemas y necesitas validar entradas en APIs, formularios, configuración o mensajes. Su coste incluye trabajo en runtime, mantenimiento del contrato y código adicional en el cliente si lo ejecutas en el navegador. Evalúa ese coste con tu aplicación, sin asumir cifras de rendimiento ajenas.
Hay otras opciones razonables:
| Opción | Cuándo considerarla |
|---|---|
| Comprobaciones manuales | Pocos campos y reglas simples que puedas mantener y probar con claridad |
| Zod | Esquemas escritos en TypeScript y tipos derivados del resultado de validación |
| Valibot | Validación con inferencia y una API modular que permite importar funciones concretas |
| Ajv | Contratos basados en JSON Schema o JSON Type Definition, especialmente si se comparten fuera de TypeScript |
Valibot documenta su enfoque modular. Ajv explica cómo compilar esquemas en funciones de validación. Son diferencias de diseño; esta comparación no es un benchmark ni afirma que una opción sea siempre más rápida o ligera en tu aplicación.
No necesitas envolver cada función interna con un esquema si ya trabaja con datos comprobados y bien tipados. Empieza por una frontera concreta: una respuesta externa que hoy se convierte en tu modelo mediante una aserción. Define qué necesitas aceptar, gestiona el fallo cerca de esa entrada y deja que el resto del código trabaje con el resultado validado. Esa mejora suele ser más útil que dispersar conversiones y comprobaciones parciales por toda la aplicación.
Referencias
- TypeScript: tipos eliminados durante la compilación, aserciones de tipo y narrowing.
- Zod: introducción y requisitos, uso básico e inferencia, definición de esquemas y formato de errores.
- JSONPlaceholder: API pública de datos ficticios utilizada en el ejemplo.
- Node.js: objetos globales:
fetchyAbortSignal. - Valibot: introducción y Ajv: primeros pasos.
