Skip to content
This page has been auto-translated and may contain errors.View in English

Inferir tipos y forzar el tipo de entrada

Un schema que acepta o lanza una excepción alcanza para describir datos. Para construir algo con eso hacen falta dos cosas más: una forma de usar esa forma de datos en tu propio código, y una forma de manejar un fallo sin que se dispare una excepción.

Empecemos con un schema que describe a un profesor:

js
import * as z from 'zod'

const teacherSchema = z.object({
  name: z.string(),
  age: z.number(),
})

Un schema, dos funciones

En TypeScript normalmente escribirías la forma de datos por segunda vez:

ts
type Teacher = {
  name: string
  age: number
}

Ahora la misma forma vive en dos lugares, y empiezan a desincronizarse la primera vez que alguien agrega un campo en uno solo de ellos.

z.infer toma el tipo directamente del schema:

ts
type Teacher = z.infer<typeof teacherSchema>
// { name: string; age: number }

Agrega una clave al schema y el tipo se actualiza solo. El schema es la definición, y TypeScript la lee.

JunoUn schema, dos funciones El typeof ahí adentro se ve raro porque no es el typeof de JavaScript que ya conoces. Esta es la versión de TypeScript, que pregunta "cuál es el tipo de este valor" a nivel de tipos.

Puedes tratar toda la línea como una sola frase: dame el tipo que describe este schema.

JunoUn schema, dos funciones La ventaja práctica es que el tipo no puede quedar desactualizado. Agrega email al schema y cada función que reciba un Teacher empieza a exigirlo, así que el compilador te muestra cada lugar que necesitas actualizar.

Si escribes el tipo a mano en cambio, sigue describiendo silenciosamente la forma vieja, lo cual es peor que no tener tipo: el código afirma una garantía que ya no cumple.

JunoUn schema, dos funciones Los tipos de entrada y de salida pueden ser distintos, algo que importa en cuanto aparecen valores por defecto y coerción. Un schema con .default() acepta un objeto sin esa clave y devuelve uno que sí la tiene, así que lo que puedes pasar y lo que obtienes de vuelta son dos formas diferentes.

z.infer te da el tipo de salida, que es el que quieres usar casi siempre, porque deberías estar leyendo el valor ya parseado y no la entrada cruda. Cuando necesitas el otro lado, para tipar lo que puede enviar quien llama, ahí está z.input. Si terminas necesitándolo, suele ser señal de que el valor crudo se está usando en algún lugar donde no debería.

Fallar sin excepciones

parse lanza una excepción. Eso funciona bien en un punto de entrada donde una solicitud mala debería detener todo, y no sirve para nada donde quieres examinar qué salió mal.

safeParse te devuelve un resultado en su lugar:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: 21 })

console.log(result)
// { success: true, data: { name: 'Jonathan', age: 21 } }

Cuando falla, la forma cambia:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: '21' })

console.log(result.success)
// false

Un éxito te da success: true y data. Un fallo te da success: false y error. Así, un formulario puede hacer la pregunta y decidir qué hacer:

js
if (result.success) {
  console.log('Se puede enviar sin problema:', result.data)
} else {
  console.log('Muéstrale al usuario qué salió mal:', result.error.issues)
}
JunoFallar sin excepciones Las dos versiones hacen la misma verificación. La diferencia está en qué te entregan cuando la respuesta es no.

parse lanza una excepción, que detiene todo a menos que la captures. safeParse devuelve un objeto con una marca success, así que puedes consultarla y seguir adelante.

JunoFallar sin excepciones Elige según lo que deba pasar después. En un manejador de ruta donde un cuerpo inválido significa una respuesta 400 y nada más, usar parse dentro de tu middleware de errores es limpio. En un formulario donde un fallo significa mostrar tres mensajes junto a tres campos, safeParse es la opción.

Un hábito que vale la pena adoptar: lee result.data, nunca el objeto original. Hoy son iguales, y dejan de serlo en cuanto entra un valor por defecto o una coerción en el schema.

JunoFallar sin excepciones El resultado es una unión discriminada, así que TypeScript lo acota por ti. Dentro de una rama if (result.success), result.data está tipado y result.error no existe; en la rama else es al revés. Verificar success primero no es una cuestión de estilo, es cómo obtienes acceso a uno u otro campo.

Vale la pena saber que parse y safeParse hacen exactamente el mismo trabajo. safeParse no es un modo más permisivo: detecta los mismos fallos y los reporta de otra forma. Tampoco hay una diferencia de rendimiento significativa entre ambos, así que la decisión pasa solo por el flujo de control.

Acotar lo que aceptas

Un tipo es un filtro grueso. z.number() acepta tanto -4 como 9e99, y ninguna de las dos es la edad de un profesor.

Los métodos se encadenan sobre un schema para hacerlo más estricto:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
  isAmerican: z.boolean().optional(),
  id: z.number().default(() => Math.random()),
})
  • .min(18) rechaza cualquier valor menor a dieciocho. .gte() y .lte() cumplen la misma función con comparaciones explícitas.
  • .optional() permite que la clave falte por completo.
  • .default() provee un valor cuando la clave está ausente, así que el objeto parseado siempre tiene uno.

Zod también incluye validaciones para formatos comunes, así que un email es una sola llamada:

js
const contactSchema = z.object({
  email: z.email(),
})

contactSchema.parse({ email: '[email protected]' })  // bien
contactSchema.parse({ email: 'jabbahuttcorp.com' })   // ZodError
JunoAcotar lo que aceptas Cada una de estas líneas se lee como una frase si la dices en voz alta. Un número, de al menos dieciocho. Un booleano, opcional. Un número, con un valor por defecto.

Eso es lo que la librería quiere decir con declarativo: describes el resultado que quieres, y ella se encarga de la verificación.

JunoAcotar lo que aceptas Los máximos son lo que la gente suele olvidar, y son justo los que importan para el tipo de abuso con el que empezó esta sección. Un campo con mínimo y sin máximo sigue aceptando diez millones de caracteres.

Ponle un .max() a cada string que almacenes. Es la respuesta más barata posible al problema de las entradas desmedidas, y pertenece al schema, no repartida por todos los manejadores.

JunoAcotar lo que aceptas.default() tiene una trampa en la que cae directo el primer ejemplo que cualquiera escribe. Escribir .default(Math.random()) llama a la función una sola vez, cuando se construye el schema, así que cada parseo durante toda la vida del proceso recibe el mismo valor idéntico. Verificado en Zod 4.5.4: dos parseos de un objeto vacío devuelven el mismo número.

Pasa una función en su lugar, .default(() => Math.random()), y se evalúa en cada parseo. Dos parseos, dos números. Lo mismo aplica a Date.now() y a cualquier id generado, y el fallo es silencioso, porque un valor por defecto que nunca cambia igual parece un valor por defecto.

Sobre z.email(): es una verificación de formato, y ninguna expresión regular puede decidir si una dirección realmente puede recibir correo. Trátala como una forma de rechazar lo obviamente mal formado, y trata un enlace de confirmación como lo que realmente establece que la dirección es real.

Forzar el tipo de lo que llega

Los campos de un formulario HTML envían strings. Todos y cada uno, incluyendo el campo etiquetado "edad" con su selector numérico al lado.

Así que un schema que espera z.number() rechaza una entrada de formulario perfectamente válida, porque '13' es un string. La coerción convierte primero y valida después:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
})

characterSchema.parse({ name: 'Luke Skywalker', episode: '4' })
// { name: 'Luke Skywalker', episode: 4 }

Entró un string, salió un número, y cualquier verificación adicional sobre esa clave se aplicó contra el número.

JunoForzar el tipo de lo que llega El orden importa aquí, y es al revés de lo que uno podría suponer. La coerción ocurre primero, y luego la verificación se aplica sobre el resultado.

Así que z.coerce.number().min(18) convierte el texto a número y después pregunta si ese número es al menos dieciocho.

JunoForzar el tipo de lo que llega Recurre a la coerción en los bordes donde el transporte pierde la información de tipo: cuerpos de formularios, cadenas de consulta, variables de entorno, filas de CSV. Ahí todo llega como texto y algo tiene que convertirlo.

Entre tus propios servicios, donde el JSON ya lleva números y booleanos reales, la coerción sobre todo esconde errores. Si un servicio te está enviando "42" donde el contrato dice que debería ser un número, quieres enterarte.

JunoForzar el tipo de lo que llega La coerción usa las propias reglas de conversión de JavaScript, que son más laxas de lo que la palabra sugiere. Dos resultados verificados en Zod 4.5.4 y ambos vale la pena recordar.

z.coerce.number() acepta un string vacío y devuelve 0, sin fallar. Un campo numérico intacto en un formulario envía "", así que un monto obligatorio se convierte silenciosamente en cero en lugar de fallar la validación. Combina la coerción con una verificación de rango, o rechaza los strings vacíos antes de parsear.

z.coerce.boolean() es peor: aplica Boolean(), así que el string "false" se convierte en true, igual que "0" y cualquier otro valor no vacío. Casi nunca es lo que quieres para un checkbox o un parámetro de consulta. Compara con los dos strings que realmente esperas y haz tú mismo la conversión.

Ponlo en práctica

Partiendo de este schema y este objeto:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.number(),
})

const character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

Haz cuatro cambios:

  1. Crea un tipo Character inferido del schema, y anota el objeto con él.
  2. Agrega al schema una clave booleana opcional llamada isJedi.
  3. Haz que episode acepte el string '6' y lo guarde como número.
  4. Registra en consola si la validación pasó o falló, como un solo booleano.
Compara tus respuestas
ts
type Character = z.infer<typeof characterSchema>

const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
  isJedi: z.boolean().optional(),
})

const character: Character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

console.log(characterSchema.safeParse(character).success)
// true

Cuatro notas sobre los cuatro cambios:

  • z.infer<typeof characterSchema> toma el tipo directamente del schema, así que agregar isJedi lo actualiza sin necesidad de una segunda edición.
  • .optional() significa que la clave puede estar ausente, por eso character no necesita isJedi y aun así pasa la validación.
  • z.coerce.number() convierte antes de verificar, así que el string '6' se convierte en el número 6.
  • .success es el booleano. Registrar solo safeParse(...) imprime todo el objeto de resultado, y parse te daría los datos o una excepción, y ninguno de los dos es un booleano.

Un detalle que vale la pena notar: con episode forzado por coerción, el tipo anotado dice number mientras que el objeto literal contiene '6'. Ahí es donde las formas de entrada y de salida difieren, y por eso importa leer result.data en lugar del objeto original.

Hacia dónde sigue esto

Un safeParse fallido devuelve un error, y hasta ahora eso ha sido algo cuya existencia comprobamos, más que algo que realmente leemos.

Leer errores de validación profundiza en eso: qué campo falló, qué tenía de malo, y cómo convertir eso en mensajes que una persona pueda usar.