Los tipos de TypeScript desaparecen en el límite de la red. En dispositivos móviles - conectividad deficiente, cachés de CDN e implementaciones de backend que compiten con tu lanzamiento en la tienda de aplicaciones - la validación en tiempo de ejecución con Zod mantiene honestos los payloads de API antes de que lleguen a tu interfaz de usuario.
// lib/api/schemas.ts - formas compartidas para endpoints de lista y detalleimport { z } from "zod";export const ApiErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string(), }),});export const UserSummarySchema = z.object({ id: z.string(), name: z.string(), avatarUrl: z.string().url().nullable(),});export const UserDetailSchema = UserSummarySchema.extend({ email: z.string().email(), createdAt: z.string().datetime(),});export const UserListSchema = z.object({ items: z.array(UserSummarySchema), nextCursor: z.string().nullable(),});export type UserSummary = z.infer<typeof UserSummarySchema>;export type UserDetail = z.infer<typeof UserDetailSchema>;export type UserList = z.infer<typeof UserListSchema>;
// lib/api/client.ts - un helper de fetch validado para la aplicaciónimport { ApiErrorSchema, UserDetailSchema, UserListSchema, type UserDetail, type UserList,} from "./schemas";const BASE = "https://api.example.com";async function readJson(res: Response): Promise<unknown> { const text = await res.text(); if (!text) return null; return JSON.parse(text) as unknown;}function throwApiError(status: number, body: unknown): never { const parsed = ApiErrorSchema.safeParse(body); if (parsed.success) { throw new Error(`${parsed.data.error.code}: ${parsed.data.error.message}`); } throw new Error(`HTTP ${status}`);}export async function fetchUserList(cursor?: string): Promise<UserList> { const url = new URL("/v1/users", BASE); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url); const json = await readJson(res); if (!res.ok) throwApiError(res.status, json); return UserListSchema.parse(json);}export async function fetchUserDetail(id: string): Promise<UserDetail> { const res = await fetch(`${BASE}/v1/users/${id}`); const json = await readJson(res); if (!res.ok) throwApiError(res.status, json); return UserDetailSchema.parse(json);}
fetch + res.json() devuelve Promise<any> en la librería DOM de TypeScript - asignar a una interfaz nombrada no valida nada en tiempo de ejecución.
Esquemas Zod caminan por el objeto analizado y coerciona o rechaza campos; z.infer proyecta el esquema en un tipo de TypeScript en compile-time.
parse lanza ZodError con un array de ruta - útil en desarrollo para ver exactamente qué campo falló.
safeParse devuelve { success, data | error } - mejor para formularios y interfaz de usuario de reintento inline donde no quieres excepciones.
En dispositivos móviles, las fallas de validación a menudo significan caché obsoleto o una respuesta parcial en una conexión perdida - registra el array de issues de ZodError en tu reportero de crashes.
import { z } from "zod";// Única fuente de verdad - exporta esquema + tipo inferido juntosexport const DeviceSchema = z.object({ id: z.string(), name: z.string(), platform: z.enum(["ios", "android"]),});export type Device = z.infer<typeof DeviceSchema>;// Estrecha unknown sin afirmaciónfunction isDevice(value: unknown): value is Device { return DeviceSchema.safeParse(value).success;}// Tipos de entrada vs salida al usar .transform o .defaulttype DeviceInput = z.input<typeof DeviceSchema>;type DeviceOutput = z.output<typeof DeviceSchema>;
Instala Zod con npx expo install zod para que la versión se mantenga compatible con tu archivo de bloqueo de Expo SDK.
Mantén esquemas en archivos .ts simples - sin JSX - para que Metro pueda importarlos desde hooks, tareas en background y pruebas.
Prefiere z.infer en lugar de duplicar una interface Device - la interfaz divergirá la primera vez que cambie la API.
Casting res.json() a una interfaz - const data = (await res.json()) as User silencia TypeScript pero no payloads malos. Solución: Asigna a unknown, luego UserSchema.parse(data).
Mismatch de opcional vs nullable - El backend envía avatarUrl: null pero el esquema usa solo .optional(); Zod rechaza la respuesta. Solución: Usa .nullable() para nulos SQL, .optional() para claves ausentes, o .nullish() para ambos.
Campos de número que llegan como strings - Algunos gateways coaccionan números en JSON. Solución:z.coerce.number() en el límite, o z.union([z.number(), z.string().transform(Number)]) cuando ambas formas aparecen.
Strings de fecha usados como Date en la interfaz de usuario - z.string().datetime() valida el formato pero deja un string; new Date(iso) en cada pantalla duplica lógica. Solución:.transform((s) => new Date(s)) una vez en el esquema.
Validar solo respuestas exitosas - Los cuerpos de error con una forma diferente lanzan ZodError opaco en helpers de fetch. Solución: Divide en !res.ok, analiza ApiErrorSchema primero, luego analiza esquema de éxito.
Respuestas de lista enormes sin esquema de paginación - Aceptar z.array(ItemSchema) cuando la API agrega { items, nextCursor } rompe cada pantalla de una vez. Solución: Modela el envelope explícitamente (UserListSchema arriba).
Cachear JSON no validado en AsyncStorage - Una versión anterior de la aplicación escribió una forma que tu nuevo esquema rechaza; los usuarios entran en crash-loop al iniciar. Solución: Versiona claves de caché (users:v2) y ejecuta safeParse al leer; expulsa en fallo.