Tag: ZOD

  • Test harness para agentes de IA: el banco de pruebas que te falta en CI

    Test harness para agentes de IA: el banco de pruebas que te falta en CI

    Nadie prueba un motor de avión montándolo en un aparato con pasajeros. Lo amarran a un banco de pruebas, le conectan sensores, le inducen fallos y miden qué aguanta. Si revienta, revienta en tierra.

    Con software tenemos el equivalente desde hace décadas y se llama test harness: el andamiaje que rodea al código bajo prueba, le inyecta entradas controladas y comprueba las salidas.

    Con agentes de IA, en cambio, la mayoría probamos en caliente. Lanzamos el agente contra una API real, miramos si el resultado "parece bien" y lo damos por bueno.

    El problema no es la pereza. Es que un agente rompe los tres supuestos sobre los que se construyó todo tu testing:

    • No es determinista: la misma entrada da salidas distintas.
    • Tiene efectos secundarios reales: escribe archivos, llama a APIs, toca bases de datos.
    • No tiene garantía de terminar: puede quedarse en bucle gastando dinero.

    Ya expliqué por qué un LLM por sí solo no es un producto y qué capas necesita alrededor para funcionar en producción. Este post va de la otra mitad del problema, la que casi nadie monta: el arnés que se ejecuta en CI, antes del deploy. Con código.


    Por qué un test unitario normal no sirve aquí

    Un test clásico es un contrato de tres líneas: preparas la entrada, ejecutas, comparas con el valor esperado.

    Con un agente, ese toEqual no existe. La respuesta correcta no es una cadena concreta, es cualquiera de un conjunto amplio de cadenas aceptables. Y si aun así escribes la aserción exacta, tendrás un test que pasa hoy y falla el martes sin que nadie haya tocado nada.

    De ahí sale la reacción habitual, que es la equivocada: dejar de testear el agente y testear solo las funciones puras que lo rodean. Los parsers, los formateadores, los validadores. Cosas que ya sabías hacer.

    Mientras tanto, lo que de verdad puede costarte dinero —el bucle, las llamadas a herramientas, el gasto— viaja a producción sin una sola comprobación.

    El arnés cambia la pregunta. En lugar de "¿ha respondido lo correcto?", que es un problema de evals, pregunta cosas que sí tienen respuesta binaria:

    • ¿Ha llamado a alguna herramienta que no tenía permitida?
    • ¿Se ha pasado del presupuesto de tokens que le di?
    • ¿Ha terminado dentro del tiempo límite?
    • ¿Ha intentado escribir fuera de su directorio temporal?
    • ¿Ha llamado 14 veces a la misma herramienta con los mismos argumentos?

    Eso son tests de verdad: deterministas, rápidos y rojos cuando algo se rompe.

       caso de prueba              TEST HARNESS                  veredicto
      ┌──────────────┐    ┌──────────────────────────────┐    ┌────────────┐
      │ entrada fija │───►│  tools falsas (sin red)      │───►│ PASS/FAIL  │
      │ estado fijo  │    │  presupuesto de tokens       │    │ trace.json │
      └──────────────┘    │  timeout + AbortSignal       │    └────────────┘
                          │  directorio efimero          │
                          └──────────────────────────────┘
    

    Las 3 piezas que hacen testeable a un agente

    1. Herramientas falsas, no red

    La regla es simple: en modo test, el agente no toca nada real. Ni base de datos, ni API de pagos, ni sistema de archivos fuera de un directorio temporal que destruyes al terminar.

    Y no basta con mockear la implementación. Hay que no exponer las herramientas no autorizadas: si el agente ve deleteUser en su lista de tools, tarde o temprano la llamará, y el error que quieres detectar en CI es precisamente ese. Un arnés que expone la herramienta y luego lanza una excepción llega tarde para razonar sobre el diseño, aunque salve los datos.

    Si necesitas ejecutar código generado de verdad —no simularlo—, ahí el aislamiento sube un nivel y toca contenedor: lo conté en Docker sandboxing para ejecutar código de IA de forma segura.

    2. Presupuesto de tokens y timeout que cortan de verdad

    Esta es la pieza que casi todo el mundo escribe mal.

    He visto docenas de arneses con un campo maxTokens en la configuración que no se comprueba en ningún sitio. Y timeouts implementados con Promise.race que devuelven el control al test pero dejan la ejecución corriendo por detrás, gastando tokens contra la API mientras el test ya ha dado verde.

    Un límite que no corta no es un límite: es un comentario.

    3. Traza reproducible

    El arnés graba cada paso: qué herramienta, con qué argumentos, cuánto tardó, cuánto costó. Un array de objetos serializado a JSON.

    Sirve para dos cosas. Para que un fallo en CI sea depurable sin volver a lanzar el agente. Y para escribir aserciones sobre el proceso, no sobre el texto final, que es donde está la señal útil: si el agente llegó al resultado correcto llamando siete veces a la misma consulta, eso es un bug aunque la salida sea perfecta.


    El arnés en TypeScript

    Vamos al código. Un arnés mínimo con presupuesto real, cancelación real y traza, sin dependencias más allá de Zod para validar los argumentos que el modelo envía a cada herramienta.

    Primero, los tipos y el registro de herramientas:

    import { z } from "zod";
    
    export interface HarnessConfig {
      maxTokens: number;
      timeoutMs: number;
      allowedTools: string[];
    }
    
    export interface TraceEntry {
      tool: string;
      args: unknown;
      durationMs: number;
      tokens: number;
    }
    
    /** Herramienta ya validada: el schema queda encapsulado dentro de `run`. */
    export interface HarnessTool {
      cost: number;
      run: (rawArgs: unknown) => Promise<unknown>;
    }
    
    export class BudgetExceededError extends Error {}
    
    /**
     * En el punto de definicion conservas el tipado completo del schema.
     * En el registro todas las tools comparten la misma firma, que es lo
     * que permite recorrerlas en bucle sin castings.
     */
    export function defineTool<S extends z.ZodType>(
      schema: S,
      cost: number,
      run: (args: z.infer<S>) => Promise<unknown>,
    ): HarnessTool {
      return { cost, run: (rawArgs) => run(schema.parse(rawArgs)) };
    }
    
    // Fixtures: nada de esto sale a la red.
    export const testTools: Record<string, HarnessTool> = {
      queryDatabase: defineTool(
        z.object({ table: z.string(), limit: z.number().max(100) }),
        320,
        async ({ table }) => ({ rows: [{ id: 1, table, name: "Fixture User" }] }),
      ),
      sendEmail: defineTool(
        z.object({ to: z.string().email(), body: z.string() }),
        90,
        async () => ({ delivered: true }),
      ),
    };
    

    Ahora el arnés. Fíjate en tres detalles: solo se construyen las herramientas permitidas, el presupuesto se comprueba antes de ejecutar cada llamada, y el temporizador se limpia siempre.

    export type HarnessStatus = "SUCCESS" | "TIMEOUT" | "BUDGET_EXCEEDED" | "FAILED";
    
    export interface HarnessResult {
      status: HarnessStatus;
      tokensUsed: number;
      durationMs: number;
      output: string | null;
      trace: TraceEntry[];
    }
    
    type ToolBox = Record<string, (args: unknown) => Promise<unknown>>;
    
    export async function runWithHarness(
      task: (tools: ToolBox, signal: AbortSignal) => Promise<string>,
      config: HarnessConfig,
    ): Promise<HarnessResult> {
      const startedAt = performance.now();
      const trace: TraceEntry[] = [];
      let tokensUsed = 0;
    
      // 1. Solo existen las tools autorizadas. El resto no se expone.
      const tools: ToolBox = {};
      for (const name of config.allowedTools) {
        const tool = testTools[name];
        // Un nombre desconocido es un error de configuracion del test: que reviente ya.
        if (!tool) throw new Error(`Tool desconocida en allowedTools: ${name}`);
    
        tools[name] = async (rawArgs: unknown) => {
          // 2. El presupuesto se comprueba ANTES de gastar.
          if (tokensUsed + tool.cost > config.maxTokens) {
            throw new BudgetExceededError(
              `Presupuesto agotado: ${tokensUsed} + ${tool.cost} > ${config.maxTokens}`,
            );
          }
          const t0 = performance.now();
          const result = await tool.run(rawArgs); // Zod valida dentro: si no cuadra, revienta
          tokensUsed += tool.cost;
          trace.push({
            tool: name,
            args: rawArgs,
            durationMs: Math.round(performance.now() - t0),
            tokens: tool.cost,
          });
          return result;
        };
      }
    
      // 3. Cancelacion real: la tarea recibe el signal y debe propagarlo al SDK.
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), config.timeoutMs);
    
      const finish = (status: HarnessStatus, output: string | null): HarnessResult => ({
        status,
        tokensUsed,
        durationMs: Math.round(performance.now() - startedAt),
        output,
        trace,
      });
    
      try {
        const output = await task(tools, controller.signal);
        return finish("SUCCESS", output);
      } catch (error) {
        if (controller.signal.aborted) return finish("TIMEOUT", null);
        if (error instanceof BudgetExceededError) return finish("BUDGET_EXCEEDED", null);
        return finish("FAILED", error instanceof Error ? error.message : String(error));
      } finally {
        clearTimeout(timer); // sin esto, el timer mantiene vivo el proceso al terminar
      }
    }
    

    Un aviso honesto sobre el punto 3: el AbortSignal solo cancela de verdad si tu tarea lo propaga al SDK del modelo y a cada fetch. Si lo ignoras, el arnés dará TIMEOUT y devolverá el control al test, pero la llamada seguirá viva por detrás y te la cobrarán igual. El signal no es decorativo: es el único mecanismo que corta el gasto.

    Y ahora sí, un test

    Con esto, probar el bucle del agente vuelve a ser testing normal:

    import { describe, expect, it } from "vitest";
    import { runWithHarness } from "./harness";
    
    describe("agente de facturación", () => {
      it("corta la ejecución al agotar el presupuesto", async () => {
        const result = await runWithHarness(
          async (tools) => {
            // Un agente en bucle: consulta la misma tabla sin parar.
            for (let i = 0; i < 20; i++) {
              await tools.queryDatabase({ table: "invoices", limit: 10 });
            }
            return "listo";
          },
          { maxTokens: 1_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
        );
    
        expect(result.status).toBe("BUDGET_EXCEEDED");
        expect(result.tokensUsed).toBeLessThanOrEqual(1_000);
        expect(result.trace).toHaveLength(3); // 3 × 320 = 960; la cuarta no cabe
      });
    
      it("no expone las herramientas fuera del allowlist", async () => {
        const result = await runWithHarness(
          async (tools) => {
            if ("sendEmail" in tools) return "PELIGRO: tool disponible";
            return "ok";
          },
          { maxTokens: 5_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
        );
    
        expect(result.output).toBe("ok");
      });
    });
    

    Deterministas, sin red, en milisegundos. Se pueden ejecutar en cada push sin pensar en la factura.

    Ese expect(result.trace).toHaveLength(3) es el tipo de aserción que solo puedes escribir si grabas la traza: comprueba el comportamiento del bucle, no el texto de salida.

    Si quieres afinar el diseño de tests y el aislamiento de dependencias externas —que es exactamente el músculo que necesitas aquí—, lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library. Y el uso de Zod para validar los argumentos que envía el modelo, con transformaciones y errores tipados, lo tienes en el curso de Zod para TypeScript.


    Qué encaja arriba y qué encaja abajo

    Tres piezas que se confunden todo el rato y conviene separar:

    Pieza Cuándo corre Qué responde
    Test harness En CI, en cada push ¿Se sale de los límites, del allowlist o del tiempo?
    Evals Por lotes, con casos reales ¿La calidad de las respuestas sube o baja?
    Agentic harness En producción, en cada ejecución ¿Cómo lo mantengo controlado con usuarios reales?

    El arnés de pruebas es el más barato de los tres y el que casi nadie tiene. Cuestión de horas montarlo, y atrapa la clase de fallo que más caro sale.

    Sobre el reparto de trabajo entre los tests que escribes tú y los que genera el agente, ya hay un post entero: adopta TDD para implementar pruebas efectivas con agentes de IA. Y sobre por qué la spec y la arquitectura no bastan sin esta capa debajo, también. Este post es la parte que faltaba: el código.

    Si trabajas con Spec-Driven Development, el encaje es directo. Los límites que escribes en la sección de NFRs del spec.md —presupuesto, latencia, herramientas permitidas— dejan de ser un párrafo y pasan a ser los argumentos de HarnessConfig. La especificación se vuelve ejecutable, que es de lo que va el libro de Spec-Driven Development.


    Lo que puedes montar esta semana

    1. Una lista blanca de herramientas por entorno. Que en test solo existan las que necesita el caso.
    2. Un presupuesto que corte. Comprobado antes de cada llamada, no después. Si tu maxTokens no aparece en ningún if, no existe.
    3. Una traza en JSON por ejecución. Y al menos un test que asierte sobre ella, no sobre el texto de salida.

    En Dominicode Labs montamos este tipo de arneses sobre agentes que corren horas sin supervisión.

    Deja de probar tus motores en pleno vuelo. Amárralos al banco, súbeles la presión hasta que rompan y arréglalos en tierra, que es donde sale barato.


    Preguntas frecuentes

    ¿Cómo se testea algo que no es determinista?

    No asertando sobre el texto de salida, sino sobre el comportamiento observable: qué herramientas llamó, con qué argumentos, cuántas veces, cuánto gastó y si terminó a tiempo. Todo eso sí es determinista y da un rojo claro cuando se rompe. La calidad de la respuesta es otra disciplina y se mide por lotes, no en cada push.

    ¿El test harness sustituye a los mocks de toda la vida?

    No, los usa. La diferencia es el alcance: un mock reemplaza una dependencia concreta, mientras que el arnés controla el entorno completo de la ejecución —qué herramientas existen, cuánto puede gastar, cuánto puede tardar y qué queda grabado—. Un mock por sí solo no impide que el agente entre en bucle.

    ¿Hay que llamar al modelo real en estos tests?

    No en los que corren en cada push: se ejecuta el bucle del agente con respuestas fijas, y eso vale para verificar límites, allowlist y control de flujo. Las ejecuciones con modelo real cuestan dinero y tardan, así que van en un job aparte, programado y sobre un conjunto reducido de casos.

    ¿Qué hago si el timeout salta pero el agente sigue gastando dinero?

    Es que estás cortando en el sitio equivocado. Promise.race devuelve el control al test pero no cancela nada: hay que crear un AbortController, pasar su signal a la tarea y propagarlo al SDK del modelo y a cada fetch. Si el SDK que usas no acepta señal de cancelación, el único corte real es aislar la ejecución en un proceso o contenedor aparte y matarlo.

    ¿Merece la pena montarlo si mi agente solo lee datos?

    Sí, por el gasto y por los bucles. Un agente de solo lectura no borra nada, pero puede repetir la misma consulta cuarenta veces y facturarte la broma entera. El presupuesto y la traza detectan ese patrón en CI, que es donde cuesta cero arreglarlo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Hace un par de años audité una aplicación enterprise en React y TypeScript que utilizaba Redux Toolkit. Para gestionar el estado de 6 pantallas principales, el equipo había tenido que escribir más de 3.500 líneas de código entre actions, reducers, selectors y Middlewares de Thunk.

    Lo grave no era la cantidad de archivos. Lo grave era que cuando el backend cambiaba un campo opcional de la API sin avisar, el store de Redux aceptaba el objeto corrupto y la aplicación explotaba páginas más tarde con el temible Cannot read properties of undefined.

    Habían creado un sistema complejo que no ofrecía ninguna protección real en tiempo de ejecución.

    La combinación de Zod (validación de esquemas) y Signals (reactividad de grano fino) se ha convertido en el estándar moderno para eliminar el dolor de la gestión de estado global en aplicaciones frontend.

    El problema de las librerías de estado tradicionales

    Durante años creímos que para gestionar el estado de una aplicación web necesitábamos un contenedor monolítico global con patrones de inmutabilidad estrictos.

    Ese enfoque sufría tres defectos estructurales:

    1. Verbosidad extrema: Escribir decenas de funciones de selección y mutación para actualizar una simple propiedad de usuario.
    2. Re-renderizados innecesarios: Si un componente escuchaba un objeto de estado global grande, cualquier cambio menor provocaba el re-renderizado del árbol de UI completo.
    3. Ceguera en la frontera API: Asumir que la respuesta del backend coincide al 100% con los tipos de TypeScript sin validar los datos entrantes.

    Como destacamos en nuestro artículo sobre programación defensiva en TypeScript, las interfaces de TypeScript desaparecen al transpilar, por lo que confiar solo en tipos en tiempo de compilación es una trampa.

    La Arquitectura Zod + Signals

    La solución moderna consiste en aplicar la validación de esquemas en la frontera de entrada (HTTP) y gestionar la reactividad atómica mediante Signals (disponibles de forma nativa en Angular, Preact, SolidJS o mediante librerías ultraligeras como @preact/signals en React).

    ┌─────────────────────────────────────────────────────────┐
    │ Respuesta API HTTP (JSON sin confiar)                   │
    │  └─► Validacion en tiempo de ejecucion con Zod Schema   │
    │     ┌───────────────────────────────────────────────────┐
    │     │ Estado Reactivo Atómico (Signals)                │
    │     │  └─► signal(), computed()                         │
    │     └───────────────────────────────────────────────────┘
    │  ┌──────────────────────────────────────────────────────┐
    │  │ Componentes de UI (Actualización Quirúrgica)         │
    │  └──────────────────────────────────────────────────────┘
    └─────────────────────────────────────────────────────────┘
    

    1. Definición del Esquema Zod y Tipado Automático

    import { z } from 'zod';
    
    // 1. Esquema con validación estricta en tiempo de ejecución
    export const UserStateSchema = z.object({
      id: z.string().uuid(),
      email: z.string().email(),
      nombre: z.string().min(2),
      rol: z.enum(['ADMIN', 'USER', 'GUEST']),
      preferencias: z.object({
        tema: z.enum(['light', 'dark']).default('dark'),
      }),
    });
    
    // Inferir el tipo de TypeScript automáticamente
    export type UserState = z.infer<typeof UserStateSchema>;
    

    2. Store Reactivo basado en Signals

    import { signal, computed } from '@preact/signals-react';
    import { UserStateSchema, UserState } from './user.schema';
    
    // State atómico inicial
    export const usuarioSignal = signal<UserState | null>(null);
    export const estaAutenticadoSignal = computed(() => usuarioSignal.value !== null);
    export const esAdminSignal = computed(() => usuarioSignal.value?.rol === 'ADMIN');
    
    // Acción de actualización con validación Zod defensiva
    export function setUsuarioConValidacion(rawData: unknown) {
      const parseResult = UserStateSchema.safeParse(rawData);
    
      if (!parseResult.success) {
        console.error('Payload de API inválido:', parseResult.error.format());
        // Se evita corromper el estado global con datos inválidos
        return false;
      }
    
      // Se asigna únicamente si la validación es 100% exitosa
      usuarioSignal.value = parseResult.data;
      return true;
    }
    

    Beneficios en Aplicaciones de Producción

    1. Re-renderizados quirúrgicos: Al consumir esAdminSignal en un botón de administración, solo ese botón se re-evalúa cuando el rol cambia. El resto de la UI permanece intacta sin necesidad de memoizaciones manuales (useMemo, React.memo).
    2. Cero corrupción de estado: Si la API devuelve un campo mal formateado, Zod detiene la propagación en la frontera HTTP antes de que afecte a la reactividad de la aplicación.
    3. Escalabilidad de código: Eliminas más del 70% del boilerplate de Redux/MobX, creando un código limpio que tanto los desarrolladores como los asistentes de IA pueden refactorizar sin riesgo.

    Al estructurar los módulos de estado siguiendo los principios de graph engineering, consigues una separación clara entre la lógica de datos y los componentes de presentación.

    Y si estás desarrollando en Angular, ten en cuenta el constante ciclo de releases de Angular donde los Signals y los Signal Forms se han integrado como el estándar nativo del framework.


    Simplificar la gestión de estado combinando la solidez de Zod con la velocidad de los Signals permite construir interfaces mantenibles, reactivas y blindadas ante fallos de producción.

    Si quieres dominar el desarrollo frontend moderno y las mejores prácticas de arquitectura con TypeScript, explora los Cursos de Dominicode. Y si quieres construir aplicaciones reales junto a otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Puedo usar Zod con otras librerías de estado como Zustand o Pinia?

    Sí. Zod es una librería de validación agnóstica al framework. Puedes usar ZodSchema.parse() dentro de las acciones de Zustand, Pinia, Redux o cualquier otra librería para validar los datos antes de guardarlos en el store.

    ¿Qué diferencia hay entre la reactividad de Signals y los Observables de RxJS?

    Los Signals están optimizados para la reactividad síncrona de UI con evaluación perezosa y seguimiento automático de dependencias. RxJS está diseñado para la coordinación de eventos asíncronos en el tiempo (peticiones HTTP, WebSockets, timers). En aplicaciones modernas, se usan Signals para el estado del componente y RxJS para streams asíncronos.

    ¿Zod añade demasiado peso al bundle del cliente?

    No. Zod es una librería ultraligera (menos de 12 KB gzippeado) y soporta tree-shaking, por lo que solo se empaquetan en el cliente los métodos y validadores que utilices explícitamente en tu código.

    ¿Cómo persiste el estado basado en Signals entre recargas de página?

    Puedes crear un efecto reactivo que sincronice automáticamente el valor del Signal con localStorage o sessionStorage cada vez que el Signal cambia, parseando los datos con Zod al restaurar la sesión.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Programación defensiva en TypeScript: casi todos la hacen mal

    Programación defensiva en TypeScript: casi todos la hacen mal

    El bug tardó dos días en encontrarse y quince segundos en arreglarse.

    El panel de facturación de un cliente mostraba 0 € de descuento a gente que sí lo tenía. Solo a veces, sin patrón. Nadie había tocado ese módulo en meses.

    La causa estaba en tres líneas: un as UserProfile sobre la respuesta del fetch, un ?? 0 sobre el descuento y, tres capas más arriba, un catch que logueaba y seguía. Tres líneas escritas para proteger el código.

    Esa es la trampa de la programación defensiva tal y como la practica casi todo el mundo: no hace el sistema más robusto, hace los fallos más silenciosos.

    El culpable de fondo era una caché que bajo carga devolvía un 200 con el perfil incompleto. Eso no llegó a ningún log.

    Qué es la programación defensiva: decidir dónde desconfías

    La programación defensiva en TypeScript es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. Se concreta en tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir.

    La versión mala la conoces: try/catch envolviendo todo, comprobar null en cada función interna, copias defensivas por si acaso, validar los argumentos de tus propios métodos privados. Mucho código de más que no atrapa nada.

    La versión que funciona son tres decisiones:

    1. Valida en las fronteras y confía en el interior.
    2. Falla rápido y ruidoso.
    3. Haz que los estados inválidos no se puedan representar.

    No es desconfiar de tu propio código. Es elegir con precisión los sitios donde desconfías —pocos, explícitos, en el borde— para poder confiar en todo lo demás.

    Guard clauses: la programación defensiva que se nota al leer

    Una guard clause es una salida temprana que valida una precondición y aborta la función antes de entrar en la lógica principal. Empieza por aquí, que es lo más barato. Esto lo he visto con nombres distintos en muchos repos:

    async function publicarPost(userId: string, draftId: string) {
      const user = await repo.findUser(userId)
      if (user) {
        if (user.plan !== 'free') {
          const draft = await repo.findDraft(draftId)
          if (draft && draft.ownerId === user.id) {
            if (draft.body.length > 0) {
              return repo.publish(draft.id)
            }
          }
        }
      }
      throw new Error('No se pudo publicar el post')
    }
    

    Cuatro niveles de indentación y un error final que no dice nada. Cuando salte en producción no sabrás si el usuario no existe, si el borrador es de otro o si venía vacío.

    Dale la vuelta:

    async function publicarPost(userId: string, draftId: string) {
      const user = await repo.findUser(userId)
      if (!user) throw new NotFoundError(`user ${userId}`)
      if (user.plan === 'free') throw new ForbiddenError(`plan free no publica: user ${user.id}`)
    
      const draft = await repo.findDraft(draftId)
      if (!draft) throw new NotFoundError(`draft ${draftId}`)
      if (draft.ownerId !== user.id) throw new ForbiddenError(`draft ${draftId} no es de ${user.id}`)
      if (draft.body.length === 0) throw new ValidationError(`draft ${draftId} sin contenido`)
    
      return repo.publish(draft.id)
    }
    

    El camino feliz queda al final, sin indentar, y cada salida lleva su motivo. No has añadido lógica: has sacado las excepciones del flujo.

    (NotFoundError, ForbiddenError y ValidationError son tres clases propias que extienden Error. El tipo del error es lo que luego mapeas a un 404, un 403 o un 422 en un único sitio.)

    Valida en las fronteras, confía en el interior

    Una frontera es cualquier sitio donde entran datos que no controlas: input de usuario, respuesta de una API externa, un fichero, un mensaje de una cola, process.env, los params de una URL.

    Ahí toca ser exhaustivo, y ahí casi nadie lo es porque TypeScript da una falsa sensación de seguridad:

    const res = await fetch(`/api/invoices/${id}`)
    const invoice = (await res.json()) as Invoice   // cero validaciones en runtime
    total += invoice.amount * invoice.rate           // ¿y si amount llega como "1250"?
    

    Ese as es una mentira que el compilador se cree: no comprueba nada, solo le prometes al type checker que confíe. Si el backend cambia amount de número a string, TypeScript sigue verde y el bug aparece dos pantallas más allá.

    Y aquí JavaScript te hace un favor envenenado. "1250" * 1.21 da 1512.5, no da error: la coerción silenciosa produce un número plausible y todo sigue funcionando. Un NaN sería una suerte, porque se ve. Lo que rompe de verdad son los casos que casi funcionan: "1.250,00" sí da NaN, y una cadena vacía da 0 — el mismo cero fantasma del principio de este post, entrando ahora por otra puerta.

    La frontera se valida con un esquema. Zod encaja bien porque el tipo sale del esquema, no al lado del esquema:

    import { z } from 'zod'
    
    const Invoice = z.object({
      id: z.uuid(),
      amount: z.number().int().nonnegative(),   // céntimos
      rate: z.number().positive(),
      status: z.enum(['draft', 'sent', 'paid']),
    })
    type Invoice = z.infer<typeof Invoice>
    
    async function fetchInvoice(id: string): Promise<Invoice> {
      const res = await fetch(`/api/invoices/${id}`)
      if (!res.ok) throw new Error(`GET /invoices/${id} devolvió ${res.status}`)
    
      try {
        return Invoice.parse(await res.json())
      } catch (cause) {
        throw new Error(`respuesta inválida de GET /invoices/${id}`, { cause })
      }
    }
    

    (Los formatos de string van al primer nivel desde Zod 4: si sigues en la 3, z.uuid() es z.string().uuid().)

    A partir de ese parse, Invoice es verdad. No un deseo. Por eso ninguna función interna vuelve a preguntar si amount es un número: revalidar lo ya validado en el borde es ruido que te hace creer que estás cubierto donde no lo estás. Para exprimir la herramienta, el curso de Zod.

    Pero el interior tiene bordes propios, y son los que nadie mira: la base de datos —una columna JSON, una migración corrida a mano, un campo que el ORM jura que no es nulo y en producción tiene nulos de 2023—, un módulo legacy sin strict en medio de tu app, y lo que devuelve una librería de terceros cuyo .d.ts solo opina. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.

    La frontera más rentable y la más ignorada es la configuración: un esquema de process.env en el arranque convierte "la app lleva dos horas fallando raro" en "la app no arranca y te dice qué falta".

    Parse, don't validate: que el tipo cargue con la prueba

    Parse, don't validate —el principio que Alexis King formuló en 2019— dice que una comprobación no debe devolver un booleano, sino un dato con un tipo más estrecho que demuestre que la comprobación ocurrió.

    Una función de validación clásica devuelve un boolean y tira la información a la basura:

    declare function isEmail(value: string): boolean
    
    if (isEmail(input)) {
      await sendWelcome(input)   // input sigue siendo string
    }
    
    // 200 líneas después, en otro fichero
    await sendWelcome(req.body.email)   // compila igual, nadie validó nada
    

    El problema no es la regex. Es que después del if no queda rastro en el sistema de tipos de que la comprobación ocurrió.

    Devuelve el dato convertido a un tipo más estrecho:

    type Email = string & { readonly __brand: 'Email' }
    
    const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    
    export function parseEmail(value: string): Email {
      const normalizado = value.trim().toLowerCase()
      if (!EMAIL_RE.test(normalizado)) throw new ValidationError(`email inválido: ${value}`)
      return normalizado as Email
    }
    
    async function sendWelcome(to: Email) { /* ... */ }
    
    const email: string = req.body.email
    
    sendWelcome(email)              // ❌ error de compilación: string no es Email
    sendWelcome(parseEmail(email))  // ✅ única forma de entrar
    

    Ya es imposible escribir a una dirección sin validar: no porque te acuerdes, sino porque no compila.

    Con una excepción que conviene saber: si el dato sale de un req.body que es any —lo que te da Express por defecto—, el any se cuela y compila igual. El brand te protege del interior; del exterior te protege el esquema de la frontera. Los dos, no uno.

    El único as que me permito es el de dentro del parseo, encerrado en cuatro líneas auditables. Con Zod tienes el atajo, aunque el tipo hay que extraerlo: const Email = z.email().brand<'Email'>() y type Email = z.infer<typeof Email>.

    Y si lo que quieres es decidir cuándo merece la pena montar un validador de esquemas, lo comparé en detalle en cuándo usar Zod en lugar de TypeScript para validar en runtime.

    Falla rápido y ruidoso: el fail fast que sí protege

    Un error que explota donde se produjo cuesta minutos de depuración. El mismo error tragado cuesta días. Los sospechosos habituales:

    try {
      applyConfig(await loadConfig())
    } catch (e) {
      console.error('error cargando config', e)   // y la app arranca con los defaults
    }
    
    const descuento = user.discount ?? 0
    const items = res.data?.items || []
    

    El catch que loguea y sigue es peor que no tener catch: además de no arreglar nada, deja la conciencia tranquila en la revisión de código. Cuando el que ejecuta el código es un agente el problema se multiplica, y de eso va cómo manejar errores en agentes de IA con TypeScript.

    Y los valores por defecto silenciosos merecen párrafo propio. ?? 0 no significa "no hay descuento", significa "no sé si hay descuento". Al escribirlo conviertes no sé en sí sé, y vale cero. Eso no es un fallback, es el bug. Con || [] igual: nadie distingue un carrito vacío de una petición que falló.

    Un valor por defecto es legítimo cuando la ausencia del dato es un estado real del dominio, no cuando es el síntoma de que algo se rompió antes.

    El compilador de TypeScript como primera línea de defensa

    Cada comprobación que mueves a tiempo de compilación es una que no escribes, ni mantienes, ni testeas.

    Lo obvio primero: strict activado, any prohibido y noUncheckedIndexedAccess si te atreves. Si arrastras un proyecto sin strict, migrar a TypeScript 6.0 con strict activado es lo primero que haría, antes de tocar nada más.

    Después, modela para que el estado imposible no exista. Este tipo permite { status: 'paid' } sin fecha, { status: 'pagado' } con typo y un pendiente con fecha de pago:

    type Pago = { status: string; paidAt?: Date; receiptUrl?: string }
    

    Este otro no permite ninguno de los tres:

    type Pago =
      | { status: 'pending' }
      | { status: 'paid'; paidAt: Date; receiptUrl: string }
    

    Y para lo que debe ser cierto siempre, el patrón assertNever:

    function assertNever(x: never): never {
      throw new Error(`caso no manejado: ${JSON.stringify(x)}`)
    }
    
    function colorDeEstado(pago: Pago): string {
      switch (pago.status) {
        case 'pending': return 'gray'
        case 'paid':    return 'green'
        default:        return assertNever(pago)
      }
    }
    

    Añade 'refunded' al union y el build se rompe señalando cada switch pendiente. Sin esa línea, el caso nuevo devuelve undefined un jueves por la tarde.

    Qué NO es programación defensiva

    Esto separa la técnica del dogma. Ninguna de estas cosas te protege:

    • try/catch global que traga. Convierte un fallo localizado en un misterio distribuido.
    • Comprobar null en funciones privadas que solo llamas tú. Si ya validaste arriba, no puede saltar nunca: código muerto que aparenta cuidado.
    • Revalidar en cada capa la forma de lo ya validado en la frontera. Si no confías en tu tipo Invoice, el problema es el tipo, no la capa. Los permisos son otra historia: eso sí se comprueba lo más cerca posible del dato, aunque ya lo hayas comprobado arriba.
    • Copias defensivas por defecto. Clonar todo lo que entra y sale cuesta, y resuelve un problema que casi nunca tienes —si publicas una librería, la copia en el borde de tu API pública sí se paga sola.
    • Programar para requisitos hipotéticos. El parámetro opcional "por si algún día" es una rama sin testear.
    Parece defensivo Qué hace en realidad Qué hacer en su lugar
    try/catch global que loguea y sigue Convierte un fallo localizado en un misterio distribuido Relanzar con cause, o manejarlo con una acción concreta
    Comprobar null en funciones privadas Código muerto que aparenta cuidado Confiar en el tipo parseado en la frontera
    Revalidar la forma en cada capa Ruido que sugiere que el tipo miente Arreglar el tipo, no añadir capas
    as sobre res.json() Silencia al compilador sin comprobar nada Esquema.parse(await res.json())
    ?? 0 sobre un dato ausente Convierte "no sé" en "sí sé, y vale cero" Fallar, o modelar la ausencia como estado del dominio

    El coste no es rendimiento, es atención. Cada comprobación de más grita "aquí puede llegar un null" cuando no puede llegar. El lector acaba ignorándolas todas, y ese es el día en que se ignora la que sí importaba.

    Es el mismo mecanismo que conté en cuándo evitar los principios SOLID: un principio aplicado por dogma, sin medir el contexto, produce peor código que no aplicarlo.

    Por qué la programación defensiva importa más con código de IA

    Nada de lo anterior es nuevo. Lo que ha cambiado es quién escribe el código.

    Una parte creciente de lo que entra en tus repos no lo has teclado tú, lo ha generado un agente. No te voy a dar un porcentaje: abre el último PR que mergeaste y cuéntalo.

    El código generado es sintácticamente impecable y plausible: se lee bien, pasa el linter, convence en diez minutos de revisión. Falla en los casos límite y en las suposiciones sobre la forma de los datos —que el endpoint siempre devuelve el campo, que el array nunca viene vacío— y reparte ?? 0 y catch silenciosos, porque ha aprendido del código defensivo mal escrito de internet.

    Eso lo detectas leyendo despacio, no en una revisión rápida. Y vas a hacer revisiones rápidas, porque el volumen ha subido — un problema que merece su propio protocolo, y del que hablé en cómo gestionar PRs generadas por agentes en la revisión de código.

    Las fronteras validadas y el fallo ruidoso son la red que atrapa eso sin depender de que revises cada línea: si el esquema está en el borde, el dato con la forma equivocada muere en el parse, lo escriba quien lo escriba. Cuanto más código generes, más vale la red. En esa dirección va cómo garantizar la confiabilidad del código generado por IA.

    Hay un segundo movimiento, de proceso: la forma de los datos es lo que la spec fija antes de que el agente escriba una línea. Es el núcleo del libro de Spec-Driven Development.

    Tres cambios que puedes hacer hoy en 30 minutos

    Tres cosas, en este orden.

    1. Escribe el esquema de process.env y párselo en el arranque. Es la frontera más tonta de tu app y la que más tiempo te devuelve.
    2. Busca catch seguido de console. Cada uno es una decisión que alguien no tomó: o lo manejas, o lo relanzas con contexto usando cause.
    3. Añade assertNever al switch más grande que tengas sobre un union de estados. Tres líneas que convierten una clase entera de bugs de runtime en errores de compilación.

    Después lleva esas reglas a tu CLAUDE.md o AGENTS.md: esquema en las fronteras, prohibido as sobre respuestas externas, prohibido catch que solo loguea. Configurar así al agente antes de que escriba una línea es el flujo que enseño en Construye con IA.

    La programación defensiva no es desconfiar de tu código. Es decidir dónde desconfías para poder confiar en el resto. Elige tres fronteras esta semana y déjalas cerradas.

    Si quieres ver estos patrones sobre proyectos reales, con el código completo, es una de las conversaciones habituales en Dominicode Labs.

    Preguntas frecuentes

    ¿Qué es la programación defensiva?

    La programación defensiva es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. En su versión útil son tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir. No consiste en llenar el código de comprobaciones por si acaso: eso esconde los bugs.

    ¿La programación defensiva es lo mismo que envolver todo en try/catch?

    No. La programación defensiva y el try/catch global son estrategias opuestas: un try/catch que captura un error, lo loguea y continúa deja el programa corriendo con datos en estado desconocido, y el fallo aparece más tarde, en otro sitio y sin rastro de su causa.

    Captura un error solo cuando puedes hacer algo concreto: reintentar, devolver un 4xx, activar un fallback que sea un estado legítimo del dominio, o relanzarlo con new Error(mensaje, { cause }) — que necesita lib: ES2022 en tu tsconfig.

    ¿Dónde están las fronteras de mi aplicación?

    Las fronteras de una aplicación son los puntos por donde entran datos cuya forma no controlas: los handlers HTTP (body, query, params, headers), las respuestas de APIs de terceros, process.env, los ficheros que lees o te suben, los mensajes de una cola o un webhook, y localStorage.

    Añade dos que casi nunca se cuentan: la base de datos, porque una columna JSON o una migración corrida a mano te devuelven cualquier cosa; y las colas, porque el payload lo escribió la versión anterior de tu propio código. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.

    ¿Los tipos de TypeScript me protegen en producción?

    No, y es el malentendido más caro. Los tipos de TypeScript desaparecen al compilar, así que en ejecución no existe ninguna comprobación. Cuando escribes const data = await res.json() as MiTipo no validas nada: silencias al compilador con una promesa que el runtime nunca verifica.

    La frontera necesita un validador de esquemas —Zod es el que uso— que compruebe la forma real del dato y devuelva un tipo. Si quieres el criterio para decidir cuándo montarlo, lo comparé en cuándo usar Zod en lugar de TypeScript para validar en runtime.

    ¿Cuánto código defensivo es demasiado?

    Una comprobación es demasiada cuando no puede saltar nunca. La regla verificable: si no puedes nombrar el caller concreto que la haría fallar, bórrala. Y si la respuesta es "ninguno, porque el dato ya viene validado de la frontera", con más razón.

    La señal de alarma es un fichero con más líneas de defensa que de lógica de negocio.

    ¿Cómo aplico la programación defensiva al código que genera un agente de IA?

    La programación defensiva se aplica al código generado fijando las fronteras antes de generar y dejándolas por escrito en las instrucciones del agente. Tres reglas en tu CLAUDE.md o AGENTS.md cubren la mayor parte: toda entrada externa se valida con un esquema, prohibido as sobre datos sin parsear, y prohibido capturar un error solo para loguearlo.

    Funciona porque no depende de que detectes el fallo leyendo: si el esquema está en el borde, el dato con la forma equivocada muere ahí, lo escriba quien lo escriba.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Construye un agente de IA en TypeScript: stack mínimo para 2026

    Construye un agente de IA en TypeScript: stack mínimo para 2026

    El stack mínimo para un agente de IA en TypeScript en 2026

    Tiempo estimado de lectura: 4 min

    Ideas clave

    • Anthropic SDK + Zod + tsx + dotenv es la combinación práctica para agentes en producción: observabilidad, tipado y control.
    • Zod como frontera: declara schemas de herramientas, valida args y convierte a JSON Schema para pasar al modelo.
    • Bucle explícito: orquesta tool-calls en un único proceso, limita iteraciones y registra cada uso.
    • No es minimalismo estético: es técnica operativa para que el equipo pueda depurar y reparar a cualquier hora.
    • Escala solo cuando métricas y requisitos lo exijan: añade memoria, orquestadores o trazas distribuidas según necesidad.

    Tabla de contenidos

    El stack mínimo propuesto es una combinación práctica y limitada de dependencias enfocadas a reducir superficie de fallo, mantener trazabilidad y controlar consumo de tokens: Anthropic SDK para el motor, Zod para contratos, tsx para ejecución TypeScript rápida y dotenv para gestionar secretos.

    Resumen rápido (lectores con prisa)

    Stack: Anthropic SDK + Zod + tsx + dotenv. Usa Zod para declarar y validar schemas de herramientas, convierte Zod a JSON Schema para pasárselo al modelo y orquesta tool-calls en un bucle explícito. Añade PostgreSQL+pgvector, orquestadores o trazas solo cuando lo exijan métricas y requisitos.

    tsx + dotenv — entorno y secretos

    tsx te permite ejecutar TypeScript directamente en Node sin compilar manualmente. En desarrollo y CI rápidos esto reduce ciclos de retroalimentación.

    dotenv mantiene las claves fuera del repo: ANTHROPIC_API_KEY, DATABASE_URL, etc. Ambos son higiene operativa, no glamour.

    Anthropic SDK — motor cognitivo directo

    Usa el SDK oficial: Anthropic SDK. Evita enrutadores genéricos que suavizan diferencias entre modelos y esconden comportamientos de tool-calling. Anthropic devuelve explícitamente cuándo el modelo quiere invocar una herramienta; tú ejecutas la función y devuelves el resultado, con control total del flujo.

    Zod — contrato entre texto probabilístico y tipos

    Zod es la frontera. Define los schemas de herramientas y valida los argumentos que el modelo genera. Convierte Zod a JSON Schema con zod-to-json-schema para declarar las herramientas al modelo. Resultado: menor tasa de alucinaciones en tool_use y errores tipo detectables y manejables.

    Por qué este stack vence en producción (ejemplos técnicos)

    1) Trazabilidad total

    Cuando el modelo pide usar una herramienta, el SDK devuelve nombre + args. Antes de ejecutar, haces schema.safeParse(args). Si falla, capturas el error, lo loggeas y agregas ese fallo al historial que reenvías al modelo. No hay retries automáticos “mágicos” que oculten la causa.

    2) Menor latencia y coste

    Un único proceso que orquesta tool-calls evita encadenados innecesarios. Si cada handoff fuera otra llamada LLM, multiplicas tokens y TTFT. Con un loop explícito controlas el número máximo de iteraciones y evitas bucles de cortesía.

    3) Menos superficie de bugs

    Las capas extra (framework + adaptadores) introducen incompatibilidades y reintentos implícitos. Tener cuatro dependencias estables reduce puntos de falla.

    El patrón de implementación: el loop explícito

    Escribes un bucle claro. Pseudodiagrama:

    1. Inicializar cliente Anthropic con la API key desde dotenv.
    2. Preparar mensajes (system + user + tool_history).
    3. Llamar a client.messages.create(…) con tool definitions derivadas de Zod.
    4. Si respuesta es texto → devolver.
    5. Si respuesta es tool_use → validar con Zod; si válido ejecutar función; añadir resultado al historial; repetir.

    Ese flujo se implementa en 30–80 líneas y es 100% controlable. No es necesario heredar de clases ni integrar callbacks crípticos.

    Validación práctica y contratos: ejemplo de herramientas

    Define una tool con Zod:

    - ticketId: z.string().regex(/^[A-Z]+-\d+$/)
    - includeComments: z.boolean().default(false)

    Convierte esto a JSON Schema y pásalo a Anthropic. Cuando el LLM devuelva args, safeParse te dice inmediatamente si se puede ejecutar. Si no, devuelves el error al modelo como contexto y le pides corrección. Ese patrón reduce las llamadas inválidas y mejora la seguridad.

    Qué no cubre este stack y cuándo añadir componentes

    • Memoria de largo plazo: integra PostgreSQL + pgvector si necesitas retrieval persistente.
    • Flujos empresariales largos (days/weeks): añade un orquestador (n8n o LangGraph) para persistencia de estado y control de aprobaciones humanas.
    • Observabilidad distribuida: añade OpenTelemetry o similar si tu cluster requiere trazas correlacionadas a escala.

    Empieza simple; añade estas piezas solo con datos que demuestren necesidad.

    Reglas operativas antes de desplegar

    • Nunca expongas una herramienta sin Zod schema.
    • Registra cada tool_use y su validación. Logs estructurados; no texto plano.
    • Limita iteraciones del loop por petición (por ejemplo, max 5 reintentos).
    • Implementa el patrón Result (ok/error) en todas las funciones ejecutadas por el agente.

    Conclusión práctica

    El stack mínimo para un agente de IA en TypeScript en 2026 devuelve poder al equipo de ingeniería: trazabilidad, tipos y control operativo. Para la mayoría de agentes productivos —consultas a APIs, limpieza de datos, consultas SQL parametrizadas— esta pila es suficiente y más fiable que una montaña de frameworks. Escala solo cuando las métricas (latencia, coste por token, fallos en producción) y los requisitos (memoria, durabilidad) lo exijan. Así evitas añadir complejidad por moda y mantienes un sistema que puedas entender, auditar y mejorar.

    Dominicode Labs

    Para quienes construyen agentes y workflows, una referencia útil y complementaria sobre prácticas operativas y plantillas de integración está disponible en Dominicode Labs. Considera consultarlo como continuación lógica al patrón de loop explícito y validación con Zod.

    Y si has llegado aquí buscando el «cómo» sin tener resuelto el «qué», empieza por ahí: antes de construirlo, qué es un agente de IA y en qué se diferencia de un workflow con un LLM dentro.

    FAQ

    ¿Por qué usar Anthropic SDK en vez de adaptadores genéricos?

    Porque el SDK oficial expone el comportamiento nativo del modelo (por ejemplo, tool_use) sin abstracciones que oculten diferencias entre modelos. Esto permite un control más preciso sobre cuándo y cómo ejecutar herramientas.

    ¿Cuál es el papel exacto de Zod en este stack?

    Zod define los schemas de las herramientas y valida los argumentos generados por el modelo. Convertir esos schemas a JSON Schema permite declararlos al modelo y reducir llamadas inválidas y alucinaciones en tool_use.

    ¿Necesito tsx en producción?

    tsx facilita ciclos de desarrollo y CI al evitar compilación manual. En producción puedes seguir usándolo o compilar, según tu pipeline; la recomendación es usarlo para reducir fricción durante desarrollo y pruebas.

    ¿Cómo reducir costes de tokens con este patrón?

    Orquesta tool-calls en un único proceso, limita iteraciones del loop y evita encadenar llamadas LLM por cada handoff. Controlar explícitamente el número de iteraciones reduce tokens enviados y latencia.

    ¿Cuándo añadir bases de datos y vectores (pgvector)?

    Añade PostgreSQL + pgvector cuando necesites retrieval persistente y la memoria a corto plazo del agente no sea suficiente para tus casos de uso.

    ¿Qué límites de seguridad operativa aplicar al expositor de herramientas?

    Nunca expongas una herramienta sin schema Zod, registra cada tool_use con logs estructurados, limita reintentos y aplica validaciones estrictas (Result ok/error) en todas las funciones ejecutadas por el agente.

  • Cómo tipar las respuestas de una LLM utilizando Zod y TypeScript

    Cómo tipar las respuestas de una LLM utilizando Zod y TypeScript

    Cómo tipar las respuestas de una LLM con Zod + TypeScript

    Tiempo estimado de lectura: 4 min

    • Valida en runtime: TypeScript desaparece en runtime; usa Zod para convertir datos inciertos en contratos verificables.
    • Esquema primero: diseña el esquema Zod como fuente única, extrae tipos con z.infer<> y serializa el esquema en el prompt.
    • Elige parse vs safeParse: .parse() para fallos severos, .safeParse() para autocorrección y reintentos.
    • Producción robusta: logging contextual, reintentos limitados, métricas y SLOs para gestionar errores LLM.

    Saber cómo tipar las respuestas de una LLM con Zod + TypeScript aparece en las primeras líneas porque es lo que evita que un agente, workflow o microservicio se rompa en producción. El modelo devuelve JSON, a menudo; nunca con la forma exacta que esperabas. .parse() y z.infer<> no son trucos: son la defensa que convierte datos inciertos en contratos verificables.

    Resumen rápido (lectores con prisa)

    Qué: Usa Zod para validar y transformar respuestas de LLM en runtime.

    Cuándo: Siempre que vayas a persistir datos o ejecutar acciones críticas basadas en la salida de una LLM.

    Por qué importa: TypeScript no valida en runtime; sin validación, datos malformados pueden romper sistemas en producción.

    Cómo funciona: Diseña el esquema Zod como fuente única, extrae tipos con z.infer<>, limpia la salida si es necesario y valida con .parse() o .safeParse().

    Por qué JSON.parse() + as es una trampa (y qué rompe)

    El patrón clásico:

    const obj = JSON.parse(response) as MyType;

    Es práctico. Es una mentira. TypeScript desaparece en runtime; as no valida nada. Los fallos típicos de LLMs:

    • JSON envuelto en bloques Markdown (“`json … “`).
    • Strings donde esperabas números (“15” vs 15).
    • Campos faltantes, claves renombradas o truncamiento por token limit.
    • Estructuras válidas pero semánticamente inválidas (amount: -5).

    Si confías en casts, esos problemas llegan a tu base de datos o a la lógica que ejecuta acciones. Validación en runtime es obligatoria.

    El patrón sólido: esquema primero, prompt segundo

    1. Diseña el esquema Zod como la única fuente de verdad.
    2. Extrae el tipo estático con z.infer<>.
    3. Incluye una versión legible del esquema en el prompt.
    4. Valida la respuesta con .safeParse() / .parse() antes de usarla.

    Este patrón convierte la salida estocástica de la LLM en una entrada controlada para tu sistema.

    Ejemplo práctico mínimo

    import { z } from 'zod';
    
    const TaskSchema = z.object({
      title: z.string().min(5),
      priority: z.enum(['low','medium','high']),
      estimatedHours: z.number().positive().optional(),
    });
    
    type Task = z.infer;

    Al pedir al modelo que devuelva JSON, serializa el esquema (o su forma) en el prompt para guiar la salida.

    Limpieza defensiva y parse()

    Los LLMs suelen añadir markdown. Limpia antes de parsear:

    function cleanJson(raw: string) {
      return raw.replace(/```json\n?|\n?```/g, '').trim();
    }
    
    const parsed = JSON.parse(cleanJson(rawOutput));
    const valid = TaskSchema.parse(parsed); // lanza ZodError si falla

    Usa .parse() cuando quieras detener el flujo y tratar la anomalía como error severo (útil en endpoints HTTP que deben devolver 4xx/5xx claros).

    .safeParse() y auto‑corrección de prompts

    En agentes autónomos o workflows escalables (n8n, agentes con herramientas), es preferible no lanzar. .safeParse() devuelve { success, data?, error? } para manejar fallos:

    const result = TaskSchema.safeParse(parsed);
    if (!result.success) {
      // registrar, emitir métricas y reintentar con autocorrección
      const zodErrors = result.error.flatten();
      // reintentar: enviar a la LLM el error y pedir JSON corregido
    }

    Patrón de autocorrección: incluye los errores de Zod en un nuevo prompt — el LLM suele corregir la estructura en el siguiente intento.

    Structured Outputs no elimina Zod

    Structured Outputs de OpenAI ayuda a reducir errores de formato (Structured Outputs). Aun así:

    • No controla cortes por token o fallos de red.
    • No valida la semántica (p. ej., números positivos).
    • No sustituye la necesidad de validar localmente antes de persistir o ejecutar acciones.

    Zod sigue siendo la última línea de defensa.

    Operativa en producción: logging, reintentos y SLOs

    • Registra errores de validación con contexto (prompt, response, zod.flatten()).
    • Implementa reintentos limitados con backoff y un máximo de autocorrecciones.
    • Mide métricas: tasa de validación fallida por modelo y prompt, latencia de corrección.
    • Decide SLOs: p. ej., si tras 2 reintentos sigue fallando, encolar para revisión humana.

    Esto transforma un fallo LLM en un incidente manejable, no en una caída silenciosa.

    Consejos prácticos y anti‑patrones

    • No uses as para confiar en la salida del modelo. Siempre valida.
    • Mantén los esquemas Zod como fuente única; evita duplicar interfaces manualmente.
    • Evita validaciones laxas (p. ej., z.any()) en bordes críticos.
    • Serializa el esquema de forma legible en el prompt, no como dump técnico que el modelo no entenderá.
    • Usa discriminated unions para estados exclusivos; obligan a la LLM a responder con casos válidos.

    Conclusión técnica

    Tipar las respuestas de una LLM con Zod + TypeScript no es una cuestión de estilo: es ingeniería de fiabilidad. .parse() y z.infer<> convierten la salida no determinista de una IA en datos verificables. Si construyes agentes, pipelines en n8n o features que actúan sobre sistemas críticos, aplicar este patrón es la diferencia entre sistemas que escalan y sistemas que requieren vigilancia humana constante. Implementa el esquema primero, valida siempre y deja que la LLM vuelva a intentarlo cuando falle — pero que falle donde tú lo controles.

    Para equipos que implementan flujos autónomos y pipelines de IA, una práctica útil es centralizar patrones de validación y autocorrección; esto es exactamente el tipo de trabajo que se experimenta en Dominicode Labs, donde se documentan plantillas y prácticas para agentes y workflows.

    FAQ

    ¿Qué es Zod y por qué usarlo?

    Zod es una librería de validación y parsing para JavaScript/TypeScript que permite definir esquemas y validar datos en runtime. Se usa para asegurar que los datos provenientes de fuentes no confiables (como LLMs) cumplen un contrato antes de ser consumidos por la aplicación.

    ¿Por qué no usar JSON.parse() + as?

    Porque as es solo una aserción en TypeScript y no realiza validación en runtime. JSON.parse() + as asume que la estructura es correcta; si no lo es, introducirás datos inválidos en tu sistema.

    ¿Cuándo usar .parse() vs .safeParse()?

    Usa .parse() cuando quieras que una anomalía detenga el flujo y sea tratada como error severo (por ejemplo, en endpoints HTTP que deben devolver 4xx/5xx). Usa .safeParse() cuando prefieras manejar el fallo (registrar, reintentar con autocorrección) sin lanzar una excepción.

    ¿Cómo incluir el esquema en el prompt?

    Serializa una versión legible del esquema en el prompt (por ejemplo, un objeto JSON con tipos esperados o una tabla de campos requeridos/formatos). Evita dumps técnicos largos; prioriza ejemplos y restricciones clave que la LLM entienda fácilmente.

    ¿Los Structured Outputs de OpenAI reemplazan a Zod?

    No. Structured Outputs reduce errores de formato, pero no controla cortes por token, fallos de red ni valida semántica (p. ej., números positivos). Zod sigue siendo necesario para validación en runtime.

    ¿Qué debo registrar cuando falla la validación?

    Registra el prompt, la respuesta cruda, el resultado de result.error.flatten() o el stack de la excepción, e información contextual (modelo, versión del prompt, intento actual). Estos datos facilitan autocorrección y análisis de causa.

  • Cómo Zod resuelve la validación de datos en TypeScript

    Cómo Zod resuelve la validación de datos en TypeScript

    Zod: la librería que todo dev TypeScript debería conocer en 2026

    Tiempo estimado de lectura: 3 min

    Ideas clave

    • Zod valida, transforma e infiere tipos en runtime, cerrando la brecha de type erasure de TypeScript.
    • Define esquemas una sola vez: validación runtime y tipos TypeScript derivados automáticamente.
    • Para DX y rapidez de integración en apps empresariales, Zod suele ser la mejor opción; para bundle size en edge, considera Valibot.
    • Patrones prácticos: esquema + inferencia, safeParse y transformaciones/refinements.

    Introducción

    Zod: la librería que todo dev TypeScript debería conocer en 2026. Lo digo sin dramatismos: si trabajas con TypeScript y datos externos, Zod debería ser parte de tu kit. Resuelve el problema fundamental que TypeScript no puede cubrir en tiempo de ejecución: validar, transformar y garantizar contratos cuando el compilador ya no está.

    Resumen rápido (lectores con prisa)

    Zod es una librería de validación y transformación de datos para runtime que infiere tipos TypeScript a partir de esquemas. Úsalo donde recibes datos externos (APIs, formularios, ficheros) para asegurar contratos en producción. Priorízalo por su balance entre experiencia de desarrollador e integración con herramientas modernas.

    Por qué Zod importa (y qué problema técnico resuelve)

    TypeScript y la brecha en runtime

    TypeScript te protege durante el desarrollo, pero los tipos se pierden al compilar (type erasure). Eso deja una brecha entre “lo que esperamos” y “lo que llega” — APIs externas, formularios, cron jobs, ficheros CSV. Sin validación en runtime, esa brecha se traduce en bugs impredecibles en producción.

    Zod colapsa dos responsabilidades que históricamente iban por separado: definición de esquema (validación runtime) y tipos TypeScript (tipado estático). Defines un esquema una sola vez; Zod infiere el tipo para que no haya duplicidad ni desincronización entre validación y tipado.

    Zod: la librería que todo dev TypeScript debería conocer en 2026 — comparación técnica

    No es magia; es diseño de librería pensado para TypeScript. Frente a alternativas:

    Yup

    • Yup: nació para JavaScript. Su soporte TypeScript es parcheado; la inferencia es frágil y obliga a aserciones manuales en proyectos estrictos.

    Valibot

    • Valibot: arquitectura modular y tree-shaking superior. Excelente para entornos edge donde cada KB cuenta.

    Zod

    • Zod: equilibrio entre DX, integración y robustez. API legible, transformaciones y refinements potentes, y amplia adopción en herramientas (tRPC, React Hook Form).

    Decisión práctica:

    • Si priorizas DX y rapidez de integración en aplicaciones empresariales: Zod.
    • Si necesitas minimizar bundle en Workers/Edge: Valibot.
    • Si mantienes legado con Yup y no puedes refactorizar ahora: sigue con Yup, pero planifica migración.

    Tutorial práctico: patrones esenciales con Zod

    Tres patrones que uso en producción: esquema + inferencia, safeParse y transformaciones.

    1) Definir esquema e inferir tipo

    // TypeScript
    import { z } from "zod";
    
    export const ProductSchema = z.object({
      id: z.string().uuid(),
      name: z.string().min(1, "Nombre obligatorio"),
      price: z.number().positive("Precio > 0"),
      category: z.enum(["tech", "food"]),
      publishedAt: z.coerce.date().optional(),
    });
    
    export type Product = z.infer<typeof ProductSchema>;

    Nota: z.coerce.date() convierte strings ISO a Date durante la validación. No necesitas un DTO separado.

    2) safeParse: predecible en producción

    const result = ProductSchema.safeParse(raw);
    
    if (!result.success) {
      // result.error.format() -> errores por campo listos para UI/LOG
      console.error(result.error.format());
      throw new Error("Payload inválido");
    }
    
    const product: Product = result.data;

    Nota: parse lanza excepciones; safeParse devuelve un objeto discriminado que encaja mejor en flujos robustos.

    3) Transformaciones y refinements

    const PriceSchema = z.string()
      .regex(/^\d+(\.\d{1,2})?$/)
      .transform(v => parseFloat(v));
    type Price = z.infer<typeof PriceSchema>; // number
    const PasswordSchema = z.object({
      password: z.string().min(8),
      confirm: z.string(),
    }).refine(data => data.password === data.confirm, {
      message: "Las contraseñas no coinciden",
      path: ["confirm"]
    });

    Integración con Angular y formularios reactivos

    El error arquitectónico común: mezclar reglas de negocio con validators en el componente. Mejor patrón: extraer la lógica a Zod y mapear errores a los controles.

    Flujo sugerido

    1. spec/schema.ts con esquemas Zod como fuente de verdad.
    2. Validador personalizado en Angular que ejecuta schema.safeParse(form.getRawValue()).
    3. Mapear error.format() a control.setErrors({ zod: mensaje }).

    Así la lógica es portable (frontend/backend), testeable y mantiene tipado estricto. Para patrones completos con Signals y Standalone Components ve el curso de integración.

    Cuando preferir otra opción

    • Valibot si tu constraint es bundle size en entornos edge.
    • Yup si tienes una base de código legacy donde migrar no es viable a corto plazo.
    • Pero en la mayoría de proyectos empresariales, Zod ofrece mejor balance entre seguridad, DX e integración con herramientas modernas.

    Conclusión y siguiente paso práctico

    Zod no es una moda: es una decisión arquitectónica que reduce errores por desincronía entre tipos y datos reales. Centraliza contratos, facilita transformaciones y mejora la trazabilidad de errores.

    Si quieres llevar esto al siguiente nivel —transformaciones avanzadas, validaciones asincrónicas y patrones de dominio— el curso práctico es la forma más rápida de internalizar buenas prácticas: Zod: Validación y Transformación de datos con TypeScript

    Aprender Zod hoy evita bugs mañana. Y eso, en producción, paga más que cualquier micro-optimización.

    FAQ

    ¿Qué es Zod?

    Zod es una librería de validación y transformación de datos para JavaScript/TypeScript que permite definir esquemas en runtime y derivar tipos TypeScript automáticamente.

    ¿Cuándo debería validar con Zod?

    Valida en cualquier borde del sistema donde recibas datos no confiables: llamadas API, formularios del usuario, ficheros externos o integraciones de terceros.

    ¿Zod reemplaza los tipos de TypeScript?

    No reemplaza los tipos estáticos del compilador; los complementa. Zod permite derivar tipos TypeScript desde un esquema único y aplica validación en runtime donde el compilador no llega.

    ¿Qué diferencia hay entre parse y safeParse?

    parse lanza una excepción si la validación falla. safeParse devuelve un objeto discriminado con success booleano y datos o error, lo que facilita flujos robustos sin excepciones inesperadas.

    ¿Puedo usar Zod en el frontend y backend con el mismo esquema?

    Sí. Mantener esquemas Zod compartidos permite que la lógica de validación y las transformaciones sean portables y consistentes entre cliente y servidor.

    ¿Cuándo elegir Valibot o Yup en lugar de Zod?

    Elige Valibot si la restricción principal es el tamaño del bundle en entornos edge. Mantén Yup solo si tienes un legado amplio que no puedes refactorizar aún; planifica migración a librerías con mejor tipado si es posible.

  • Asegura el tipo de datos en function calling usando TypeScript

    Asegura el tipo de datos en function calling usando TypeScript

    Function calling tipado con TypeScript: deja de adivinar lo que devuelve el modelo

    Tiempo estimado de lectura: 4 min

    • Ideas clave:
    • Los LLMs fallan en formato y semántica: validar la salida evita errores en producción.
    • Define esquemas con Zod, deriva tipos con z.infer<>, y valida antes de ejecutar herramientas.
    • Usa .parse() para fallar rápido en endpoints y .safeParse() para autocorrección en agentes.
    • Mide y registra: trazabilidad completa (prompt, response, error de Zod, tool invocada).

    Introducción

    Cuando un agente llama a una herramienta, el modelo genera un JSON con argumentos. Asumir que ese JSON tendrá la forma correcta es la fuente de la mayoría de fallos en producción. Implementar Function calling tipado con TypeScript: deja de adivinar lo que devuelve el modelo no es opcional: es ingeniería defensiva. Con Zod validas en runtime, con z.infer<> obtienes tipos sincronizados y con un framework que integre ambos cierras el círculo.

    Fuentes útiles: Vercel AI SDK, Zod, OpenAI Structured Outputs.

    Resumen rápido (lectores con prisa)

    Qué es: Validación tipada de la salida de modelos mediante Zod y TypeScript.

    Cuándo usarlo: Siempre que un LLM invoque herramientas, modifique estado o llame APIs críticas.

    Por qué importa: Previene errores por campos faltantes, tipos incorrectos o JSON mal formado en producción.

    Cómo funciona: Define esquemas Zod, deriva tipos con z.infer<>, valida con .parse() o .safeParse() antes de ejecutar.

    Por qué tipar el function calling importa ahora

    Los LLMs fallan de formas predecibles: omiten campos, envían strings en vez de números, rodean JSON con Markdown o inventan claves. Si procesas ese output con JSON.parse() y as Tipo, renuncias a la seguridad de TypeScript en runtime. El resultado: escrituras corruptas en bases de datos, llamadas a APIs con parámetros inválidos y bugs que sólo aparecen semanas después.

    La alternativa técnica es clara:

    • declarar el esquema con Zod,
    • derivar el tipo TypeScript con z.infer<>,
    • validar antes de ejecutar la herramienta.

    Eso convierte la entrada del agente en un contrato matemático que protege tu lógica de negocio.

    Arquitectura práctica: esquema → validación → ejecución

    Patrón recomendado:

    1. Define el esquema Zod y añádele descripciones que el LLM pueda leer.
    2. Expón ese esquema en el prompt (o úsalo con Structured Outputs).
    3. Valida la respuesta del LLM con .safeParse() o .parse() antes de llamar a la función.
    4. Si falla, captura el ZodError, loguéalo y opcionalmente reintenta con autocorrección.

    Código mínimo (ejemplo de consulta de divisas)

    import { tool } from 'ai'; // p. ej. Vercel AI SDK
    import { z } from 'zod';
    
    const ExchangeSchema = z.object({
      base: z.string().length(3).toUpperCase().describe('Moneda base ISO 4217, ej. USD'),
      target: z.string().length(3).toUpperCase().describe('Moneda destino ISO 4217, ej. EUR'),
    });
    
    type ExchangeParams = z.infer;
    
    export const getExchangeRate = tool({
      description: 'Devuelve el tipo de cambio entre dos monedas',
      parameters: ExchangeSchema,
      execute: async ({ base, target }: ExchangeParams) => {
        const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${base}`);
        if (!res.ok) throw new Error('API externa falló');
        const data = await res.json();
        return { rate: data.rates[target] };
      }
    });
    

    Si la validación falla, execute nunca se ejecuta: el SDK/Zod detiene la cadena y devuelve un error estructurado.

    .parse() vs .safeParse() y autocorrección

    Usa .parse() cuando quieras fallar rápido (endpoints HTTP que deben devolver 4xx/5xx). Usa .safeParse() en agentes y workflows que puedan auto‑corregirse sin intervención humana.

    Patrón de autocorrección:

    1. LLM genera JSON.
    2. .safeParse() devuelve success: false y error.
    3. Serializas error.flatten() y lo inyectas en un nuevo prompt: “Tu respuesta falló por X. Corrige el JSON.”
    4. Reintentás N veces con backoff; si sigue fallando, encolas para revisión humana.

    Ese ciclo convierte errores estructurales en una conversación de corrección con el modelo, robusta y trazable.

    Operaciones y observabilidad

    No basta con validar: mide y actúa.

    Métricas recomendadas:

    • tasa de validación fallida por prompt/modelo,
    • latencia media de autocorrección,
    • número de reintentos hasta éxito,
    • porcentaje de degradaciones a intervención humana.

    Registra siempre: prompt, raw response, resultado de Zod (.error.flatten()), y el tool invocado. Eso te da trazabilidad: prompt → response → validación → acción. Sin esos registros no hay postmortem útil.

    Decisiones arquitectónicas y trade‑offs

    – Structured Outputs (OpenAI) y generateObject reducen errores de formato pero no sustituyen la validación semántica: un amount: -5 puede pasar el schema si no validas signo y rango. Siempre valida con Zod (https://zod.dev/).

    – Tipar desde el día 0 exige disciplina: los esquemas son contratos que obligan a diseñar prompts claros y a mantener tests de integración. La deuda que previene compensa la inversión inicial.

    – En entornos orquestados (n8n, XState) preferir que el LLM decida la herramienta y que la ejecución quede en una máquina de estado puede ser más seguro para acciones críticas. Igual aplica: la entrada debe validarse antes de actuar.

    Conclusión: deja de adivinar, empieza a garantizar

    Function calling tipado con TypeScript: deja de adivinar lo que devuelve el modelo — es una fórmula sencilla y comprobada: define el esquema (Zod), extrae el tipo (z.infer<>), valida antes de ejecutar y automatiza la corrección cuando tenga sentido. Esa disciplina transforma un LLM impredecible en un componente confiable de tu arquitectura. Si tu agente escribe en bases de datos, llama APIs facturadas o toma decisiones que afectan a clientes, no hay excusas: valida antes de ejecutar y loguea todo. Así se construyen agentes que pueden correr solos, y no problemas que sólo aparecen en producción.

    Para continuar explorando prácticas operativas y experimentos en automatización e IA aplicada, consulta Dominicode Labs. Esta referencia complementa las técnicas descritas y ofrece recursos prácticos para implementar pipelines seguros y trazables en producción.

    FAQ

    ¿Por qué no basta con hacer JSON.parse() y castear a un tipo?

    Porque JSON.parse() solo asegura formato JSON válido, no la semántica ni la presencia y tipo de campos esperados. Castear con as Tipo ignora la verificación en runtime, lo que permite entradas inválidas que pueden provocar errores en bases de datos o llamadas a APIs en producción.

    ¿Cuándo debo usar .parse() en lugar de .safeParse()?

    Usa .parse() en contextos donde quieras fallar rápido y retornar un error (por ejemplo endpoints HTTP que deben devolver 4xx/5xx). Usa .safeParse() cuando el flujo puede intentar autocorrección o reintentos antes de degradar a intervención humana.

    ¿Qué hago si .safeParse() falla continuamente?

    Serializa el error con error.flatten(), inyecta esa información en un nuevo prompt pidiendo corrección, y reintenta N veces con backoff. Si sigue fallando, encola la unidad para revisión humana y registra el incidente para análisis posterior.

    ¿Debo exponer el esquema Zod en el prompt?

    Sí: exponer el esquema ayuda al modelo a generar la estructura correcta (especialmente con Structured Outputs). Aun así, la validación con Zod debe ejecutarse en runtime; el esquema en el prompt no sustituye la verificación.

    ¿Qué debo registrar para tener trazabilidad adecuada?

    Registra el prompt, la respuesta cruda del modelo, el resultado de Zod (error.flatten()), y la herramienta (tool) invocada. Esos datos permiten reconstruir el flujo prompt → response → validación → acción para postmortems.

    ¿Los Structured Outputs sustituyen la validación con Zod?

    No. Structured Outputs y utilidades como generateObject reducen errores de formato, pero no validan semántica ni rangos (por ejemplo, amount: -5 podría pasar). Sigue validando con Zod en runtime.

  • Cómo evitar el uso de `any` en TypeScript y mejorar tu código

    Cómo evitar el uso de `any` en TypeScript y mejorar tu código

    ¿Sigues parcheando con any porque “es más rápido”? Felicidades: acabas de convertir a tu compilador en un cómplice silencioso de los bugs nocturnos.

    Tiempo estimado de lectura: 5 min

    • TypeScript no te salva por arte de magia: es una herramienta que hay que configurar y aplicar, no decoración del IDE.
    • No uses any como parche: usa unknown o validación runtime para datos externos.
    • Activa strict y prioriza validación en la frontera: tsconfig, linters, CI y validadores como Zod.

    Introducción

    Poca gente lo dice tan claro: TypeScript no te salva automáticamente. Si lo tratas como decoración del IDE, te dará una falsa sensación de seguridad. Y cuando las cosas se rompan en producción, nadie recordará quién puso ese as any a las tres de la mañana.

    Voy a ser directo. Esto es lo que rompe proyectos y cómo lo arreglas para que deje de romperlos.

    Resumen rápido (lectores con prisa)

    TypeScript proporciona tipos estáticos para detectar errores tempranos en desarrollo. No valida tipos en runtime; para datos externos hay que usar validación en la frontera (por ejemplo Zod) y mantener "strict": true en tsconfig. Evita any y el operador de aserción no-nula !; prefiere unknown, encadenamiento opcional y guard clauses. Integra linters y CI que ejecuten tsc --noEmit y pruebas de contratos para evitar deuda técnica silenciosa.

    Qué falla y cómo lo arreglas

    1) Deja de usar any como parche rápido

    any = apagar las comprobaciones. unknown = obligarte a pensar.

    Usa unknown cuando no conoces la forma de un dato. Forcear any es como cerrar los ojos y conducir a 140 km/h: puedes llegar, o no.

    Ejemplo idiota, pero real:

    // NO
    function process(payload: any) {
      console.log(payload.name.toUpperCase());
    }
    
    // SÍ
    function processSafe(payload: unknown) {
      if (typeof payload === 'object' && payload !== null && 'name' in payload) {
        console.log((payload as { name: string }).name.toUpperCase());
      }
    }
    

    No te apetece escribir esa comprobación ahora. Perfecto: pon una validación con Zod y delega la detección a la frontera.

    2) Activa strict. No negociable.

    Si tu tsconfig dice "strict": false estás firmando cheques a la deuda técnica.

    Síntomas: parámetros sin tipado, nulos que aparecen sin avisar, inicialización incompleta de clases. Solución simple: "strict": true y arreglar las fallas una por una. ¿Migración? Usa @ts-expect-error puntualmente. No rebajes la seguridad global.

    3) El operador ! es una mentira elegante

    usuario.address!.street parece limpio. Es una bomba.

    Mejor: encadenamiento opcional o guard clauses.

    • usuario.address?.street — seguro, devuelve undefined.
    • if (!usuario.address) return — claro, explícito.

    Si ven ! en un PR, que explique por qué. Si no puede explicar, rechaza el PR.

    4) as T NO valida datos externos

    const data = await res.json() as User es confiarle la vida a un string.

    En la frontera (red, persistencia, input externo), usa validación runtime. Zod, io-ts, AJV: cualquiera que genere el guard que puedas correr al recibir datos. Y sí: extrae el esquema a un único lugar para que el tipo y el validador sean la misma verdad.

    Ejemplo con Zod:

    import { z } from 'zod';
    const UserSchema = z.object({ id: z.string(), name: z.string() });
    type User = z.infer;
    
    const raw = await res.json();
    const user = UserSchema.parse(raw); // lanza si no concuerda
    

    5) La “gimnasia de tipos” rompe equipos

    Type Gymnastics — esos tipos de 40 líneas que solo el autor entiende — son deuda técnica disfrazada. Úsalos donde aporten valor: librerías, infra, utilities core. No en cada componente. Si un tipo necesita 30 minutos para entenderlo, lo estás usando mal.

    Buenas prácticas: alias cortos, nombres claros, ejemplos en comentarios y tests de tipos (tsd) para que no se rompa en silencio.

    Checklist operativo (copia y pega y aplica ya)

    • tsconfig.json: "strict": true
    • ESLint: activar reglas
      • @typescript-eslint/no-explicit-any: error
      • @typescript-eslint/strict-boolean-expressions: warn/error
    • Pre-commit: husky + lint-staged para bloquear any y !
    • CI: job que ejecuta tsc --noEmit y tests de contratos (Zod parse)
    • Boundary validation: todas las respuestas HTTP parseadas con Zod/io-ts
    • Tests de tipos con tsd en PRs críticos

    Snippet de CI (esqueleto GitHub Actions)

    name: Validate Types
    on: [pull_request]
    jobs:
      types:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
          - run: pnpm install
          - run: pnpm build # incluye tsc --noEmit
          - run: pnpm test:contracts # tests que hacen parse de esquemas Zod
    

    Cómo aplicarlo en equipos (cultura > herramientas)

    • Regla simple: todo dato que venga de fuera pasa por un esquemas.
    • PRs deben documentar la asunción: “Por qué este ! está justificado”.
    • Prohibe any en lint. No excepciones.
    • Revisión de tipos en pair programming para cambios complejos.
    • Añade tests e2e que verifiquen la integración real del esquema (no solo mocks).

    Errores típicos y la respuesta corta

    • “No activamos strict porque produce demasiados errores” → Activarlo e ir resolviendo; cada corrección es una deuda pagada.
    • “Validar en runtime es caro” → El coste es mínimo comparado con el tiempo perdido debugueando un null en producción.
    • “Los tipos complejos son elegantes” → Sí. Elegantes y peligrosos si nadie los entiende.

    Mini-guía de herramientas que te salvan la vida

    • Zod: validación runtime + inferencia de tipos. Ganador simple.
    • @typescript-eslint: reglas para prohibir any, !, etc.
    • tsd: tests de tipos que evitan roturas silenciosas.
    • husky + lint-staged: bloqueo en pre-commit de malas prácticas.
    • Sentry / logs: cuando la validación falla en producción, registra payload + endpoint.

    Métrica que deberías vigilar semanalmente

    • PRs con any encontrados: objetivo = 0.
    • Rechazos por ! sin justificación: objetivo = 0.
    • Número de validaciones runtime faltantes en endpoints críticos: objetivo = 0.

    Si tienes más de 1 en cualquiera, tienes trabajo de deuda técnica.

    Cierre sin azúcar: esto es disciplina, no postureo

    TypeScript te da herramientas para poner reglas fuertes y claras en el código. Sin disciplina, esas reglas se convierten en adorno. Cambiar la cultura es más duro que tocar tsconfig, pero mucho más rentable.

    ¿Quieres algo práctico para arrancar mañana? Te puedo enviar:

    • Un repo template con tsconfig, ESLint, husky, Zod y pruebas de contratos listas.
    • Un workflow de GitHub Actions que falla el CI si hay any o ! no justificado.
    • Un checklist PDF para code reviews centrado en tipado seguro.

    Respóndeme “Envíame el template” o “Quiero el workflow” y te lo paso listo. No es sexy. Es lo que evita que pases la noche arreglando prod por culpa de un as any. Esto no acaba aquí.

    FAQ

    ¿Por qué no debo usar any?

    Porque desactiva las comprobaciones de tipos y oculta errores que el compilador podría detectar. Usar any convierte el compilador en cómplice de bugs que aparecen en producción.

    ¿Cuándo usar unknown en lugar de any?

    Usa unknown cuando recibes datos sin estructura conocida. Obliga a validar o refinar el tipo antes de operar sobre el valor, evitando asunciones peligrosas.

    ¿Qué hace exactamente “strict”: true?

    Activa un conjunto de opciones de compilador que refuerzan la seguridad de tipos: strictNullChecks, noImplicitAny, strictBindCallApply, entre otras. Detecta parámetros sin tipado, nulos no manejados y problemas de inicialización.

    ¿Cómo valido respuestas HTTP correctamente?

    Usa validación runtime en la frontera con esquemas compartidos (por ejemplo Zod). Parseas el payload recibido con el esquema y manejas el error si no concuerda antes de propagar datos al resto de la aplicación.

    ¿Qué hacer si activar strict rompe demasiados archivos a la vez?

    Actívalo y corrige las fallas progresivamente. Para casos puntuales usa @ts-expect-error temporalmente, pero no como solución permanente.

    ¿Cómo evitar que los tipos complejos sean incomprensibles?

    Prefiere alias cortos y nombres claros, añade ejemplos y tests de tipos (tsd) y reserva tipos largos para librerías o infraestrutura donde el equipo esté alineado.

  • Cuándo usar Zod en lugar de TypeScript para validación en runtime

    Cuándo usar Zod en lugar de TypeScript para validación en runtime

    Zod vs TypeScript puro: cuándo usar validación en runtime

    ¿Confías en TypeScript para proteger tu app en producción? Deja de hacerlo. TypeScript es un analizador estático; su trabajo termina cuando el código se compila. Si quieres seguridad real en ejecución necesitas otra cosa. Aquí va la guía práctica: Zod vs TypeScript puro: cuándo usar validación en runtime.

    TypeScript ordena tu código. Zod protege tus fronteras. Úsalos juntos, no en guerra.

    Tiempo estimado de lectura: 4 min

    • TypeScript es un analizador estático: no protege en runtime.
    • Zod parsea en runtime y mantiene una sola fuente de verdad con z.infer.
    • Valida en las fronteras (endpoints, webhooks, env, uploads); confía en TypeScript dentro del dominio.
    • Mide impacto de rendimiento y evita validaciones redundantes en el core.

    ¿Confías en TypeScript para proteger tu app en producción? Deja de hacerlo. TypeScript es un analizador estático; su trabajo termina cuando el código se compila. Si quieres seguridad real en ejecución necesitas otra cosa. Aquí va la guía práctica: Zod vs TypeScript puro: cuándo usar validación en runtime.

    Resumen rápido (lectores con prisa)

    Qué es: Zod es una librería de validación y parsing en runtime; TypeScript es un sistema de tipos estático.

    Cuándo usarlo: Usa Zod en las fronteras (endpoints, webhooks, env, uploads); usa TypeScript dentro del dominio.

    Por qué importa: TypeScript sufre type erasure y no impide fallos en producción; Zod parsea y falla rápido.

    Cómo usarlo: Definir esquemas Zod, derivar tipos con z.infer y parsear payloads en los handlers.

    Por qué TypeScript no basta (y dónde duele más)

    TypeScript tiene un problema estructural: Type Erasure. Los tipos desaparecen al compilar. Eso significa que las aserciones (as T) son mentiras que el compilador acepta y la app paga en runtime.

    interface Usuario { id: number; email: string; }
    
    const datos = await respuesta.json() as Usuario;
    console.log(datos.email.toLowerCase()); // Boom si email no existe
    

    Si la API cambia, o devuelve HTML en un error 500, tu código explota. TypeScript no está ahí para detenerlo.

    Zod y el principio “Parse, don’t validate”

    Zod opera en runtime. Su filosofía es clara: no intentes “validar” por inercia; parsea y falla rápido.

    import { z } from 'zod';
    
    const UsuarioSchema = z.object({
      id: z.number(),
      email: z.string().email(),
    });
    
    type Usuario = z.infer;
    
    const res = await fetch(url);
    const raw = await res.json();
    const usuario = UsuarioSchema.parse(raw); // lanza si algo falla
    console.log(usuario.email.toLowerCase());
    

    Con z.infer mantienes una sola fuente de verdad: el esquema. Nada de duplicar interfaces y validadores.

    safeParse vs parse

    Si quieres manejo de errores controlado:

    const result = UsuarioSchema.safeParse(raw);
    if (!result.success) {
      // logging, métricas, respuesta 400 al cliente...
      throw new Error('Payload inválido');
    }
    const usuario = result.data;
    

    safeParse devuelve un objeto con success y error para flujos menos crudos.

    Dónde aplicar validación (regla práctica)

    No todo necesita Zod. La regla del arquitecto es simple:

    • Valida en runtime en las fronteras: entrada HTTP, webhooks, archivos subidos, variables de entorno, LocalStorage.
    • Confía en TypeScript dentro del dominio: funciones internas, paso de props entre componentes, mutaciones internas en stores tipados.

    Aplicar Zod en todas partes degrada rendimiento y readability. No validar en las fronteras te expone a fallos catastróficos.

    Casos prácticos y patterns

    1) Respuesta API (backend → frontend o backend → backend)

    • Parsea siempre antes de usar.
    • Registra el error y devuelve 400 o fallback claro.

    2) Webhooks / n8n / automatizaciones

    • Parsea y verifica firma si aplica.
    • Rechaza rápido para evitar procesar datos corruptos.

    3) Variables de entorno en arranque (ejemplo con Zod)

    const EnvSchema = z.object({
      DATABASE_URL: z.string().url(),
      NODE_ENV: z.enum(['development','production']),
    });
    const env = EnvSchema.parse(process.env);
    

    Arranque fallido = fallo visible y evitar estados inconsistentes.

    4) Formularios en frontend

    Integra Zod con React Hook Form para validar antes de mutar el estado o enviar al servidor.

    Coste y alternativas: cuándo preocuparse por rendimiento

    Zod añade CPU y peso al bundle. En la mayoría de apps esto es irrelevante frente a la estabilidad que gana tu producto. En sistemas de altísima demanda (millones de eventos por segundo) evalúa alternativas más ligeras o validaciones a medida.

    Alternativas emergentes existen, pero la elección debe basarse en mediciones. No te cases con una librería sin perf tests en tu caso real.

    Integración práctica: patrón recomendado

    1. Punto de entrada (API handler, webhook) → Zod parse
    2. Convertir a tipos con z.infer → pasar al core tipado con TypeScript
    3. Lógica interna → TypeScript puro, sin comprobaciones redundantes
    4. En el cliente, validar inputs críticos; en el servidor, validar todo lo externo

    Conclusión y pasos accionables

    • Zod y TypeScript no son excluyentes. TypeScript organiza tu código; Zod lo hace seguro en producción.
    • Si tienes que elegir hoy, empieza por defender las fronteras: añade validación Zod en endpoints y webhooks.
    • Usa z.infer para que el tipo estático derive del esquema y mide impacto.

    Recuerda: el compilador no vive en producción. Tú sí. Protege lo que importa.

    Prueba esto ahora: añade un esquema Zod a uno de tus endpoints y corre safeParse con un payload inesperado. Verás la diferencia. Esto no acaba aquí; la próxima nota tratará cómo estructurar esquemas Zod para versiones y migraciones sin romper consumidores.

    FAQ

    ¿TypeScript protege mi app en producción?

    No. TypeScript es un analizador estático y sus tipos desaparecen al compilar (type erasure). No impide que datos inválidos lleguen a runtime.

    ¿Qué es Zod y por qué usarlo?

    Zod es una librería de validación y parsing en runtime. Su filosofía es “parse, don’t validate”: parsea datos y falla rápido si no coinciden con el esquema, evitando errores silenciosos en producción.

    ¿Cuándo usar parse vs safeParse?

    Usa parse cuando quieras que el proceso lance inmediatamente y falle rápido. Usa safeParse para manejar errores de forma controlada (logging, métricas, respuesta 400) sin exceptions no controladas.

    ¿Dónde aplicar validación en mi arquitectura?

    Valida en las fronteras: endpoints HTTP, webhooks (p. ej. n8n), archivos subidos, variables de entorno, y LocalStorage. Dentro del dominio confía en TypeScript y evita validaciones redundantes.

    ¿Zod impacta tanto el rendimiento?

    Zod añade CPU y peso al bundle, pero en la mayoría de apps el coste es aceptable frente a la estabilidad que aporta. En sistemas de altísima demanda evalúa alternativas más ligeras y mide con perf tests.

    ¿Cómo integrar Zod con TypeScript sin duplicar tipos?

    Define esquemas Zod y deriva los tipos estáticos con z.infer. Así mantienes una sola fuente de verdad: el esquema.