Tag: ZOD

  • JEV AI Typesafe: integraciones más seguras en producción

    JEV AI Typesafe: integraciones más seguras en producción

    Todo desarrollador de TypeScript ha vivido este espejismo: creas un schema con Zod, se lo pasas al modelo con response_format: { type: "json_schema" }, la llamada no revienta en runtime y respiras aliviado. Si compila y valida, está bien.

    Luego miras la base de datos a las tres de la mañana.

    El modelo tenía que clasificar si un usuario pedía la baja de su cuenta o soporte técnico. El JSON validó perfectamente contra el enum ['CANCEL_ACCOUNT', 'TECH_SUPPORT']. El tipo era intachable. Pero el usuario solo preguntaba cuánto costaba renovar, y el modelo le asignó CANCEL_ACCOUNT con el 100% de validez sintáctica. Tu sistema le borró la cuenta sin un solo error en Sentry.

    Ese es el peligro del que nadie habla cuando te venden "seguridad de tipos en IA": confundir validez de tipo con veracidad semántica.

    En corto: Jev garantiza que la salida respeta los tipos que has definido (noul, choice, score) sin errores de parseo ni campos inventados. Pero eso es seguridad sintáctica. Para una integración segura de verdad necesitas tres cosas más: umbrales de confidence calibrados con tus propios datos, contratos Zod en la frontera de tu dominio, y asumir que el state que le mandas puede venir escrito por quien quiere manipular la respuesta.


    ¿Qué significa realmente "type safe" en Jev?

    Significa que la salida del modelo no es texto libre al que un parser externo le pone una camisa de fuerza, sino un conjunto de primitivas discretas ligadas a los tipos que tú declaras.

    En un LLM con structured outputs, el modelo genera texto token a token y una gramática rechaza los tokens que violan el schema. Por debajo sigue siendo un generador de texto al que le han cerrado las salidas.

    En Jev la diferencia es anterior: el modelo no está entrenado para generar texto. Lo dice su propia documentación de limitaciones, en la sección donde explica por qué no puede darte una explicación en prosa de sus decisiones. Lo que devuelve son las tres primitivas: la opción elegida de una lista que tú das (choice), una probabilidad entre 0 y 1 (noul) o una media ponderada sobre una rúbrica ordenada (score).

    Un matiz importante: cómo funciona eso por dentro no es público. No hay paper ni descripción de la arquitectura. Lo verificable es el contrato de salida, no el mecanismo.

    Enfoque Dónde se valida el tipo Riesgo de JSON roto Campos inventados Señal de incertidumbre
    Prompt clásico a un LLM En tu código, tras JSON.parse() Alto (Markdown, cortes) Alto Ninguna
    JSON Schema / tool calling Capa de decodificación del proveedor Bajo Medio Ninguna calibrada
    Jev En la propia respuesta del modelo Ninguno: solo devuelve tus claves Ninguno confidence calibrada

    Lo que esa tabla no dice, y es lo que importa: ninguna de las tres filas te protege de un valor válido y equivocado.


    La alucinación perfectamente tipada

    En el hilo de Hacker News del lanzamiento, uno de los comentarios que mejor resume el riesgo lo dejó clarísimo:

    "Sure, it can't emit an invalid type, but it can still emit a completely wrong valid value. You can enforce structured output from an LLM too, with an appropriate harness."

    Que una variable sea de tipo 'FRAUDE' | 'LEGITIMO' no significa que el usuario sea un defraudador. Significa que TypeScript no se va a quejar cuando invoques bloquearTarjeta().

    Por eso una integración segura con Jev no termina en el SDK: empieza en cómo conectas sus probabilidades con tus reglas de negocio.


    Patrón de integración blindada con TypeScript y Zod

    import { TypeSafeClient, choice, score, noul } from '@typesafe-ai/sdk'
    import { z } from 'zod'
    
    // 1. Nuestras categorías de dominio
    const AccionSeguridadSchema = z.enum(['IGNORAR', 'AUDITAR', 'BLOQUEAR_CUENTA'])
    type AccionSeguridad = z.infer<typeof AccionSeguridadSchema>
    
    // 2. Contrato de salida verificado
    const DecisionSeguridadSchema = z.object({
      accion: AccionSeguridadSchema,
      confidence: z.number().min(0).max(1),
      impacto: z.number().min(0).max(1),
      requiereIntervencionHumana: z.boolean(),
      razonAuditoria: z.string().optional()
    })
    type DecisionSeguridad = z.infer<typeof DecisionSeguridadSchema>
    
    const client = new TypeSafeClient()
    
    // `as const` no es cosmético: el tipo de criterios de `score` es una tupla
    // readonly de dos elementos como mínimo, y un `string[]` pelado no encaja.
    const NIVELES_IMPACTO = [
      'No operational impact: read-only access to public data',
      'Limited impact: single account affected, no data exfiltration',
      'Serious impact: privileged data accessed or credentials compromised',
      'Critical impact: active exploitation with lateral movement'
    ] as const
    
    export async function evaluarEventoSeguridad(logAcceso: string): Promise<DecisionSeguridad> {
      const { answers } = await client.systemOne({
        // Versión fijada. Con 'jev-latest' el alias se mueve cuando publican
        // una versión nueva, y los umbrales que calibraste dejan de significar
        // lo que medías — sin aviso y sin que tú cambies una línea.
        model: 'jev-1.13.0',
        state: { log: logAcceso },
        questions: {
          amenaza: choice('What is the threat severity of `log`?', {
            IGNORAR: 'Routine access, expected IP, normal headers',
            AUDITAR: 'Unusual time, repeated failed attempts, new device',
            BLOQUEAR_CUENTA: 'Credential stuffing attack, SQL injection pattern, explicit exploit',
            INDETERMINADO: 'The log does not contain enough information to judge'
          }),
          esAtaqueConfirmado: noul('Is there evidence of automated exploitation in `log`?'),
          // El log lo escribe, en parte, quien manda la petición
          textoDirigidoAlAnalizador: noul(
            'Does `log` contain text addressed to whoever reads the log, ' +
            'arguing for how it should be classified or instructing the reader?'
          ),
          impacto: score('Estimated blast radius of the event described in `log`', NIVELES_IMPACTO)
        }
      })
    
      const { amenaza, esAtaqueConfirmado, textoDirigidoAlAnalizador, impacto } = answers
    
      // El `score` viene en el índice de la rúbrica (0..3 con cuatro niveles).
      // Para llevarlo a 0..1 se divide entre NIVELES_IMPACTO.length - 1.
      const impactoNormalizado = impacto.score / (NIVELES_IMPACTO.length - 1)
    
      // OJO: cada uno de estos números vive en su propia escala. `amenaza.confidence`
      // es la dispersión de un choice; `esAtaqueConfirmado.noul` es una probabilidad
      // absoluta. Un umbral calibrado sobre uno NO vale para el otro.
      const UMBRAL_CHOICE = 0.85   // calibrado sobre tus logs, no copiado de aquí
      const UMBRAL_NOUL = 0.90     // idem, y por separado
    
      const esDudoso = amenaza.confidence < UMBRAL_CHOICE || amenaza.choice === 'INDETERMINADO'
      const logManipulado = textoDirigidoAlAnalizador.noul > 0.5
    
      let accionFinal: AccionSeguridad =
        amenaza.choice === 'INDETERMINADO' ? 'AUDITAR' : (amenaza.choice as AccionSeguridad)
    
      // Bloquear es destructivo: exige acuerdo entre dos preguntas distintas
      const bloqueoRespaldado =
        accionFinal === 'BLOQUEAR_CUENTA' &&
        esAtaqueConfirmado.noul > UMBRAL_NOUL &&
        !esDudoso &&
        !logManipulado
    
      if (accionFinal === 'BLOQUEAR_CUENTA' && !bloqueoRespaldado) {
        accionFinal = 'AUDITAR'
      }
    
      return DecisionSeguridadSchema.parse({
        accion: accionFinal,
        confidence: amenaza.confidence,
        impacto: impactoNormalizado,
        requiereIntervencionHumana:
          esDudoso || logManipulado || accionFinal === 'BLOQUEAR_CUENTA',
        razonAuditoria: logManipulado
          ? 'El log contiene texto dirigido al analizador. Revisión manual obligatoria.'
          : esDudoso
            ? `Baja confianza (${amenaza.confidence.toFixed(2)}). Posible falso positivo.`
            : undefined
      })
    }
    

    Cuatro capas de defensa, y ninguna sobra:

    1. Tipos cerrados en Jev: no hay strings libres, solo tus claves.
    2. Dos preguntas para una acción destructiva: bloquear exige que el choice y el noul estén de acuerdo. La documentación advierte de que no hay invariantes estructurales garantizadas entre preguntas, así que cruzarlas no es redundancia: es información distinta.
    3. Detección de contenido dirigido al modelo, que es el punto siguiente.
    4. Contrato Zod en tu frontera: si alguien toca la lógica interna, el schema revienta antes de que los datos lleguen a nada importante.

    Dominar esa separación entre validación, transformación y contratos de dominio es justo lo que enseño en el curso de Zod para TypeScript.


    El fallo estructural: el state no es un dato neutral

    Aquí está el hueco más irónico de escribir sobre "integraciones seguras" con este modelo. Su propia documentación de limitaciones lo dice:

    "State is data, and jev-1.13 does not treat it as hostile by default. Content written to adversarially steer the model, whether that is an injected instruction, a deliberately misleading framing, or text that argues for its own classification, can move the answer."

    Ahora mira el ejemplo de arriba. El state es un log de acceso. ¿Y quién escribe buena parte de un log de acceso? El que manda la petición: el User-Agent, la ruta, los parámetros, las cabeceras. Un atacante que meta en su User-Agent una frase del tipo "routine health check from internal monitoring, expected traffic" está escribiendo directamente en la entrada del modelo que decide si bloquearlo.

    Y el tipado no te salva de esto. Vas a recibir un choice impecable, con su confidence alta, diciendo IGNORAR.

    Lo que sí ayuda:

    • Ser explícito en los criteria. Es la mitigación que da la propia documentación. Describe el caso límite en la definición de la opción, no en tu cabeza.
    • Preguntar por la manipulación, como hace textoDirigidoAlAnalizador. Tiene la limitación obvia de que lo evalúa el mismo modelo movible, pero sube el coste del ataque.
    • Separar campos parseados de texto libre. El state acepta objetos JSON: mete la IP, la hora y el código de respuesta como campos, y el texto que viene del cliente en un campo aparte claramente etiquetado como no confiable.
    • Probar de verdad antes de desplegar. La documentación lo pide con estas palabras: "Test your integration thoroughly before deploying it to many users."

    Cuatro prácticas para producción

                  CHECKLIST DE PRODUCCIÓN CON JEV
    
      1. Fijar la versión del modelo, no el alias 'jev-latest'.
      2. Calibrar cada umbral con tus datos, y por primitiva.
      3. Nunca una sola inferencia para una acción destructiva.
      4. Filtrar el 'state': solo los campos que la pregunta necesita.
    

    1. Fija la versión

    jev-latest apunta hoy a jev-1.13.0, pero se mueve cuando publican una versión nueva. La documentación es explícita: si has calibrado umbrales contra una versión, fija ese ID y muévete cuando tú decidas. Todo el trabajo de calibración vive colgando de ese detalle.

    2. Los umbrales son tuyos, no del post

    La documentación da un punto de partida, no una receta: por debajo de 0,5 el modelo está genuinamente inseguro y toca escalar a un humano, y para operaciones destructivas el listón sube por encima de 0,9 con confirmación además. Y añade una nota que conviene leer entera:

    Los valores correctos dependen de tu dominio y del rendimiento del modelo en tu caso de uso. Empieza conservador, prueba con tus propios datos y ajusta según los resultados.

    Y un aviso que cuesta dinero aprender por las bravas: un umbral afinado sobre un noul no vale para un choice. La documentación lo demuestra con la misma pregunta hecha de las dos maneras — un noul de 0,22 frente a un choice que da 0,99 al "no" con confianza 0,97. Y dos noul complementarios que suman 1,19 en lugar de 1.

    3. Taxonomías que no se solapan

    Si en un choice defines dos opciones casi idénticas, Jev repartirá la probabilidad entre ambas y la confidence se hundirá aunque haya entendido el caso perfectamente. Esto no es teoría: la confidence se calcula precisamente a partir de cómo de repartida está la distribución. Plano es poca confianza; un pico es mucha.

    Opciones mutuamente excluyentes, y una salida del tipo INDETERMINADO o "ninguna de las anteriores" cuando la lista pueda no cubrirlo todo. Caben hasta 255 opciones por pregunta y cada una cuesta unos pocos tokens, así que no hay motivo para quedarse corto.

    4. Estado limpio

    Jev lee todo lo que metes en state. Si le pasas un volcado con timestamps, IDs de sesión y hashes de cookies, ese ruido actúa como distractor y la precisión cae — palabras de la documentación, no mías. Además te deja sin saber qué parte de la entrada produjo la respuesta mala.

    Y hay dos techos que respetar: 64k tokens por petición, y 32k para el state más la pregunta más larga. El que te limita de verdad suele ser el segundo.


    Cierre accionable

    Construir software con IA no es cruzar los dedos para que el modelo no rompa el JSON. Es diseñar sistemas donde cada transición de estado esté acotada por tipos, umbrales calibrados y límites deterministas, y donde la entrada no confiable se trate como lo que es.

    Jev te quita un problema real —el parseo frágil— y te deja los dos difíciles: decidir cuándo te fías de un número y qué haces cuando el texto que analizas está escrito para engañarte.

    Para la metodología de especificación previa al código, ahí está Spec-Driven Development.

    Si estás montando agentes completos donde estas decisiones alimentan a workers de fondo, el curso Construye con IA tiene el paso a paso.

    Y si necesitas un harness de pruebas para evaluar la fiabilidad antes de desplegar, descarga gratis el ebook Revisión por Contrato.


    El problema del state y las prácticas de producción tienen un capítulo propio en Jev y las decisiones tipadas con IA: cómo te va a fallar Jev, qué no puede verificar nadie todavía y cómo escribir el código para poder salir.

    Preguntas frecuentes

    ¿Garantiza Jev que la opción seleccionada existe en mi código?

    Sí. El SDK de TypeScript infiere los tipos de las respuestas a partir de las preguntas que declaras. Si defines opciones { si: '...', no: '...' }, el tipo de answers.pregunta.choice es 'si' | 'no'. Ahí no hay sorpresas: las sorpresas están en cuál de las dos te devuelve.

    ¿Por qué no usar TypeChat o Instructor sobre un LLM normal?

    Esas librerías fuerzan a un modelo generativo a emitir JSON a base de reintentos y corrección de prompts. Si falla, pagas otra vez la latencia y los tokens. Jev resuelve el tipado en una sola pasada, en unos 250 ms end-to-end medidos, sin reintentos. Lo que no te resuelve ninguno de los dos es si el valor es correcto.

    ¿Qué pasa si mando un estado vacío o una pregunta mal formada?

    La API responde 422 Unprocessable Entity, con el cuerpo detallando el campo que falla. No es un 400. Los otros que verás son 401 si la clave está mal, 429 si te pasas de los límites y 529 si están saturados; para los dos últimos, reintento con backoff exponencial — los SDK oficiales ya lo hacen por defecto.

    ¿Suman 1 las probabilidades de un choice?

    Sí, dentro de una misma pregunta: la documentación lo garantiza. En tus tests compara con tolerancia (< 1e-6), no con igualdad exacta. Lo que no suma 1 son dos noul complementarios: la documentación muestra un caso que da 1,19.

    ¿Puedo validar estructuras anidadas complejas?

    No directamente. Jev opera sobre tres primitivas y no devuelve grafos ni listas de objetos. Para estados complejos, agrupas varias preguntas tipadas en una sola llamada —se evalúan en paralelo y apenas añaden latencia— y ensamblas el resultado en tu código.


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

  • Validación en NestJS: DTOs, class-validator y sus trampas

    Validación en NestJS: DTOs, class-validator y sus trampas

    El bug tardó once días en aparecer y cuatro minutos en explicarse.

    Una API NestJS, endpoint de registro. El controlador recibía el body, lo pasaba al servicio, el servicio lo pasaba al ORM. Limpio, corto, elegante. Alguien mandó un role: "admin" de más en el JSON y se creó una cuenta con permisos de administrador.

    Lo que más duele: el proyecto tenía validación en NestJS. Tenía DTOs con decoradores. Tenía el ValidationPipe registrado globalmente. Y aun así el campo pasó.

    Porque el pipe estaba puesto sin opciones. new ValidationPipe(), tal cual. Eso valida que los campos declarados cumplan sus reglas, pero no elimina los que no declaraste. Y en una API, lo que no declaras es exactamente lo que te van a mandar.

    La validación en NestJS bien montada no es una capa de seguridad más. Es el contrato entre el mundo exterior y tu aplicación. Cuando lo defines bien, un montón de código defensivo que hoy vive en tus servicios simplemente desaparece.


    El DTO en NestJS es un contrato, no un tipo

    Un DTO (Data Transfer Object) en NestJS es una clase que define la forma exacta de los datos que un endpoint acepta. No la forma que esperas: la que aceptas.

    // src/users/dto/create-user.dto.ts
    import {
      IsEmail,
      IsInt,
      IsOptional,
      IsString,
      MaxLength,
      Min,
      MinLength,
    } from 'class-validator';
    
    export class CreateUserDto {
      @IsEmail({}, { message: 'El email no tiene un formato válido' })
      email: string;
    
      @IsString()
      @MinLength(12, { message: 'La contraseña necesita al menos 12 caracteres' })
      password: string;
    
      @IsString()
      @MinLength(2)
      @MaxLength(60)
      fullName: string;
    
      @IsOptional()
      @IsInt()
      @Min(18)
      age?: number;
    }
    

    En el controlador no haces nada especial:

    @Post()
    create(@Body() dto: CreateUserDto) {
      return this.usersService.create(dto);
    }
    

    Y aquí viene la primera regla que no se negocia: CreateUserDto tiene que ser una class, nunca una interface.

    Las interfaces de TypeScript desaparecen al compilar. Nest lee el tipo del parámetro en runtime con reflect-metadata; si es una interface, el metatype que recibe es Object, no hay decoradores que leer, y el pipe deja pasar el payload entero sin decir nada. Es un fallo silencioso, que son los peores — y es el mismo problema de fondo que trato en cómo tipar correctamente una API REST en TypeScript: el tipo estático no te protege de lo que llega por el cable.


    ValidationPipe en NestJS: las opciones que sí importan

    El ValidationPipe de NestJS es el pipe integrado que intercepta el payload de una request, lo compara contra los decoradores del DTO usando class-validator y lanza un 400 Bad Request con la lista de errores si algo no cumple. Viene en @nestjs/common y no valida nada por sí solo: todo depende de las opciones con las que lo construyas.

    Registra el pipe globalmente y configúralo. Este es el bloque que uso en producción:

    // src/main.ts
    import { ValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      app.useGlobalPipes(
        new ValidationPipe({
          whitelist: true,
          forbidNonWhitelisted: true,
          forbidUnknownValues: true,
          transform: true,
          transformOptions: { enableImplicitConversion: false },
          stopAtFirstError: false,
        }),
      );
    
      await app.listen(process.env.PORT ?? 3000);
    }
    bootstrap();
    

    Qué hace cada una, sin adornos.

    Opción Por defecto en Nest Recomendado Qué te juegas
    whitelist false true Sin ella, las propiedades que no declaras en el DTO llegan intactas al servicio
    forbidNonWhitelisted false true Sin ella los campos de más se borran en silencio en vez de devolver un 400
    forbidUnknownValues false (Nest lo fuerza) true Con false, un objeto sin metadatos de class-validator pasa la validación entera
    transform false true Sin ella recibes un objeto literal, no una instancia del DTO: sin métodos ni getters
    transformOptions.enableImplicitConversion false false Si lo pones a true, ?onlyActive=false activa el filtro, porque Boolean('false') es true
    stopAtFirstError false false Con true devuelves un error por request en vez de todos los del formulario de una vez

    whitelist: true elimina del objeto toda propiedad que no tenga al menos un decorador de validación. Este flag por sí solo habría evitado el bug de la historia. role no estaba en el DTO, así que se habría borrado antes de llegar al servicio.

    forbidNonWhitelisted: true sube la apuesta: en lugar de borrar en silencio, devuelve un 400 diciendo qué propiedad sobra. Lo prefiero, porque el silencio de whitelist a secas te oculta que el frontend lleva tres sprints mandando un campo muerto.

    transform: true convierte el JSON plano en una instancia real de la clase. Sin esto recibes un objeto literal con la forma correcta, no un CreateUserDto: si tu DTO tiene métodos o getters, no existen.

    stopAtFirstError viene en false por defecto en class-validator, y así lo dejo: quiero devolver todos los errores del formulario de una vez, no obligar al cliente a hacer seis viajes.

    Y luego está forbidUnknownValues, que merece su propia sección porque es la trampa gorda.


    La trampa: forbidUnknownValues no vale lo que crees

    La documentación de class-validator es tajante: forbidUnknownValues vale true por defecto y recomienda no tocarlo, porque desactivarlo hace que objetos desconocidos pasen la validación.

    Ahora mira el constructor del ValidationPipe de Nest:

    // packages/common/pipes/validation.pipe.ts
    this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions };
    

    Nest lo pone a false salvo que tú lo pidas explícitamente. Es una decisión deliberada de compatibilidad hacia atrás (viene del issue 10683), no un despiste. Pero el efecto práctico es que la opción que class-validator considera crítica está desactivada por defecto en tu API de Nest.

    Muerde cuando el pipe valida un objeto del que class-validator no tiene metadatos: un DTO sin decoradores, uno que olvidaste importar bien, una clase generada dinámicamente. Con false eso pasa limpiamente. Con true falla y te enteras.

    Ponlo a true explícitamente y pasa tu suite después. Si algo se rompe, es que algo no se estaba validando.


    Objetos anidados: @ValidateNested sin @Type no valida nada

    @ValidateNested() solo valida un objeto anidado si va acompañado de @Type(() => Clase). Sin @Type, class-validator no sabe en qué clase instanciar el valor, no encuentra metadatos y deja pasar el objeto entero. Es la segunda trampa, y la he visto en más proyectos que la anterior.

    // src/orders/dto/create-order.dto.ts
    import { Type } from 'class-transformer';
    import {
      ArrayMinSize,
      IsArray,
      IsInt,
      IsNotEmpty,
      IsString,
      Matches,
      Min,
      ValidateNested,
    } from 'class-validator';
    
    export class AddressDto {
      @IsString()
      @IsNotEmpty()
      street: string;
    
      @IsString()
      @IsNotEmpty()
      city: string;
    
      @Matches(/^\d{5}$/, { message: 'El código postal debe tener 5 dígitos' })
      zipCode: string;
    }
    
    export class OrderItemDto {
      @IsString()
      @IsNotEmpty()
      sku: string;
    
      @IsInt()
      @Min(1)
      quantity: number;
    }
    
    export class CreateOrderDto {
      @IsString()
      @IsNotEmpty()
      customerId: string;
    
      @ValidateNested()
      @Type(() => AddressDto)
      shippingAddress: AddressDto;
    
      @IsArray()
      @ArrayMinSize(1)
      @ValidateNested({ each: true })
      @Type(() => OrderItemDto)
      items: OrderItemDto[];
    }
    

    @Type() viene de class-transformer, no de class-validator, y es la que le dice en qué clase instanciar el objeto anidado.

    Si la quitas, el anidado se queda como objeto plano, @ValidateNested() no encuentra metadatos asociados a ese valor y no comprueba nada. La request pasa y shippingAddress llega a tu servicio con lo que sea que mandaran.

    @ValidateNested sin @Type es decoración. Van siempre en pareja. Y en arrays, { each: true } es obligatorio o solo validas el array como un todo.


    Update sin duplicar el DTO

    No copies y pegues el DTO de create para poner todo opcional.

    // src/users/dto/update-user.dto.ts
    import { OmitType, PartialType } from '@nestjs/mapped-types';
    import { CreateUserDto } from './create-user.dto';
    
    export class UpdateUserDto extends PartialType(
      OmitType(CreateUserDto, ['email'] as const),
    ) {}
    

    PartialType hace todas las propiedades opcionales manteniendo sus reglas. OmitType quita las que no deben poder cambiarse. Tienes también PickType e IntersectionType.

    Aviso de la documentación oficial que cuesta caro ignorar: si usas @nestjs/swagger o @nestjs/graphql, importa los mapped types desde esos paquetes, no desde @nestjs/mapped-types. La doc oficial lo deja en "efectos secundarios varios y no documentados", sin concretar. En mi experiencia se manifiesta casi siempre como esquemas OpenAPI vacíos que nadie sabe explicar.


    Query params y la conversión implícita

    Los query params llegan siempre como string. Aquí es donde mucha gente activa enableImplicitConversion: true y se olvida del tema. Mala idea.

    // src/users/dto/find-users-query.dto.ts
    import { Transform, Type } from 'class-transformer';
    import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
    
    export class FindUsersQueryDto {
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      page: number = 1;
    
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      @Max(100)
      limit: number = 20;
    
      @IsOptional()
      @Transform(({ value }) => value === 'true' || value === true)
      @IsBoolean()
      onlyActive: boolean = false;
    
      @IsOptional()
      @IsString()
      search?: string;
    }
    

    onlyActive lo transformo a mano por una razón muy concreta.

    Cuando activas enableImplicitConversion, class-transformer usa el tipo reflejado por TypeScript y aplica el constructor correspondiente. Para booleanos, el código es literalmente return Boolean(value).

    Y Boolean('false') en JavaScript es true. Cualquier string no vacío lo es.

    Es decir: ?onlyActive=false te activa el filtro. Tu API hace lo contrario de lo que pide el cliente, devuelve un 200 y no aparece un solo error en los logs. Lo he depurado dos veces y las dos me llevó más de una hora.

    Deja enableImplicitConversion en false y sé explícito propiedad a propiedad con @Type() y @Transform(). Más verboso, y correcto.


    Validadores custom: cuando el decorador no existe

    Los decoradores integrados cubren tipo y formato. Las reglas de negocio no. Para eso escribes una clase que implementa ValidatorConstraintInterface.

    // src/users/validators/is-email-available.validator.ts
    import { Injectable } from '@nestjs/common';
    import {
      ValidationArguments,
      ValidatorConstraint,
      ValidatorConstraintInterface,
    } from 'class-validator';
    import { UsersRepository } from '../users.repository';
    
    @ValidatorConstraint({ name: 'isEmailAvailable', async: true })
    @Injectable()
    export class IsEmailAvailableConstraint implements ValidatorConstraintInterface {
      constructor(private readonly users: UsersRepository) {}
    
      async validate(email: unknown): Promise<boolean> {
        if (typeof email !== 'string') return false;
        const existing = await this.users.findByEmail(email.toLowerCase());
        return existing === null;
      }
    
      defaultMessage(args: ValidationArguments): string {
        return `El email ${args.value} ya está registrado`;
      }
    }
    

    Lo enganchas al DTO con @Validate:

    import { IsEmail, Validate } from 'class-validator';
    import { IsEmailAvailableConstraint } from '../validators/is-email-available.validator';
    
    export class CreateUserDto {
      @IsEmail()
      @Validate(IsEmailAvailableConstraint)
      email: string;
    
      // ...resto de propiedades
    }
    

    Para que la inyección de dependencias funcione necesitas dos cosas. Primero, declarar el constraint como provider en su módulo. Segundo, decirle a class-validator que use el contenedor de Nest:

    // src/main.ts
    import { useContainer } from 'class-validator';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      useContainer(app.select(AppModule), { fallbackOnErrors: true });
      // ...el resto del bootstrap: useGlobalPipes, listen
    }
    

    fallbackOnErrors: true no es opcional: sin él, Nest lanza una excepción en cuanto class-validator le pide al contenedor una clase que no está registrada como provider.

    Una advertencia: consultar la base de datos durante la validación es best effort, no una garantía. Entre que el validador pregunta y el servicio inserta hay una ventana de carrera. El índice único de la tabla sigue siendo la fuente de verdad; el validador solo sirve para devolver un 400 legible en vez de un 500 con un error del driver.

    A favor juega el orden del ciclo de vida de Nest: los pipes se ejecutan después de los guards. Cuando ese validador toca la base de datos, la request ya está autenticada.


    Testea el validador como lógica pura

    Un validador custom es una clase con una dependencia. No necesitas levantar un TestingModule.

    // src/users/validators/is-email-available.validator.spec.ts
    import { IsEmailAvailableConstraint } from './is-email-available.validator';
    
    describe('IsEmailAvailableConstraint', () => {
      const usersRepository = { findByEmail: jest.fn() };
      const constraint = new IsEmailAvailableConstraint(usersRepository as never);
    
      beforeEach(() => jest.resetAllMocks());
    
      it('acepta un email que no existe', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await expect(constraint.validate('nuevo@dominicode.com')).resolves.toBe(true);
      });
    
      it('rechaza un email ya registrado', async () => {
        usersRepository.findByEmail.mockResolvedValue({ id: '1' });
        await expect(constraint.validate('bezael@dominicode.com')).resolves.toBe(false);
      });
    
      it('normaliza a minúsculas antes de consultar', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await constraint.validate('Bezael@Dominicode.com');
        expect(usersRepository.findByEmail).toHaveBeenCalledWith('bezael@dominicode.com');
      });
    });
    

    Tres tests, cero infraestructura. Si tu validador necesita un módulo entero para poder testearse, tiene demasiada responsabilidad.


    Cuándo NO usar class-validator

    class-validator es la librería de decoradores (@IsEmail, @MinLength, @ValidateNested) sobre la que NestJS construye toda su validación de entrada. No es un paquete de Nest: es un proyecto independiente, y ahí está la parte incómoda.

    class-validator va por la 0.15.1, publicada el 26 de febrero de 2026. El parón fuerte fue entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025): dieciséis meses sin release. Desde entonces ha recuperado ritmo — 0.14.3 en noviembre de 2025, 0.14.4 y 0.15.1 en febrero de 2026. Está vivo.

    class-transformer es otra historia. Su última versión publicada es la 0.5.1, de noviembre de 2021. Casi cinco años sin release, y es la pieza de la que dependen @Type, @Transform, transform: true y toda la conversión implícita que acabamos de ver. No está roto, pero tampoco se está arreglando.

    En paralelo, NestJS 12 salió el 27 de agosto de 2026 con soporte nativo de Standard Schema. Los decoradores de parámetro aceptan una opción schema, y hay un pipe nuevo para validarla:

    // src/users/schemas/create-user.schema.ts
    import { z } from 'zod';
    
    export const createUserSchema = z.strictObject({
      email: z.email(),
      password: z.string().min(12),
      fullName: z.string().min(2).max(60),
      age: z.number().int().min(18).optional(),
    });
    
    export type CreateUserDto = z.infer<typeof createUserSchema>;
    
    // src/main.ts
    import { StandardSchemaValidationPipe } from '@nestjs/common';
    
    app.useGlobalPipes(new StandardSchemaValidationPipe());
    
    // src/users/users.controller.ts
    @Post()
    create(@Body({ schema: createUserSchema }) body: CreateUserDto) {
      return this.usersService.create(body);
    }
    

    Aquí CreateUserDto es un tipo inferido, no una clase. El esquema y el tipo son la misma cosa, así que no puedes desincronizarlos. Con decoradores son dos verdades separadas que mantienes a mano, y ahí es donde entran los bugs.

    La comparación honesta:

    class-validator Zod / Standard Schema
    Fuente de verdad Tipo + decoradores (dos) Esquema (una)
    DI en validadores Sí, vía useContainer No de serie
    Mapped types (PartialType) Sí .partial(), .omit(), .pick()
    OpenAPI @nestjs/swagger maduro Nativo en v12, o nestjs-zod
    Mantenimiento Lento (class-transformer congelado) Activo
    Reutilizar en el frontend No Sí, mismo esquema

    Mi criterio, sin vender humo: si tu proyecto ya es class-based de arriba abajo (entidades TypeORM, Swagger, validadores con DI), class-validator sigue siendo el camino de menor fricción y no hay que migrarlo por moda.

    Si empiezas hoy en Nest 12, si compartes contratos con un frontend TypeScript, o si validas salidas de un LLM —donde necesitas parsear, transformar y reintentar en el mismo sitio—, Zod gana con claridad. Si sigues en v11 y quieres esa ruta, nestjs-zod (5.5.0, julio de 2026) te da createZodDto, el pipe y la serialización de respuestas. Ojo: sus peer dependencies todavía declaran @nestjs/common ^10 || ^11, así que para v12 aún no es opción.

    Sobre este tema escribí a fondo en validación en runtime con Zod y TypeScript, y si quieres dominar la librería entera —transformaciones, refinamientos, esquemas compuestos— la trabajo paso a paso en el curso de Zod para TypeScript.

    Y si aún estás eligiendo framework, esta capa de validación es uno de los argumentos de más peso a favor de Nest frente a opciones más ligeras, como analicé en Hono vs NestJS vs Express y al mirar la alternativa más directa a Nest, ExpressoTS 4.0.


    Checklist: revisa hoy tu validación en NestJS

    Abre tu main.ts. Si ves new ValidationPipe() sin opciones, ya tienes trabajo para los próximos veinte minutos:

    1. Añade whitelist: true y forbidNonWhitelisted: true.
    2. Pon forbidUnknownValues: true explícitamente y ejecuta tu suite de tests.
    3. Busca en el proyecto @ValidateNested y comprueba que cada uno tiene su @Type() al lado.
    4. Si tienes enableImplicitConversion: true, quítalo y haz explícitas las conversiones.

    Después vete a tus servicios y borra los if (!dto.email) throw .... Esos guardias existen porque en algún momento nadie confió en la entrada. Cuando el contrato vive en el DTO, sobran: es el principio que desarrollo en programación defensiva en TypeScript, donde la mejor defensa es la que se aplica una vez, en el borde, y no en cada función.

    Esa es la fortaleza silenciosa de NestJS. No es que valide. Es que, bien montado, te deja escribir servicios que asumen datos correctos porque lo son.

    Si quieres verlo aplicado sobre un proyecto real, con la capa de validación, los tests y las decisiones de arquitectura completas, lo trabajamos en Dominicode Labs. Y si lo que te interesa es NestJS llevado al terreno de la IA, monté el streaming de respuestas en tiempo real con el Vercel AI SDK sobre esta misma base. En vídeo, subo NestJS y arquitectura backend cada semana en el canal de YouTube.


    Preguntas frecuentes

    ¿Qué es un DTO en NestJS?

    Un DTO (Data Transfer Object) en NestJS es una clase que describe la forma exacta del payload que un endpoint acepta: qué propiedades existen, de qué tipo son y qué reglas cumplen. Se declara con decoradores de class-validator y se usa como tipo del parámetro @Body(), @Query() o @Param() en el controlador.

    Tiene que ser una class y no una interface: las interfaces desaparecen al compilar y en runtime no queda nada a lo que asociar los decoradores. Y no es solo documentación — con el ValidationPipe configurado, el DTO es lo que decide qué request entra y cuál se rechaza con un 400.

    ¿Puedo usar una interface en lugar de una clase para el DTO?

    No, si quieres que se valide. Las interfaces desaparecen en la transpilación, así que en runtime no hay nada a lo que asociar los decoradores: Nest recibe Object como metatype y el ValidationPipe deja pasar el payload entero.

    Si te molesta escribir clases, la alternativa real es la ruta de esquemas: con Zod y el StandardSchemaValidationPipe de Nest 12 el DTO sí puede ser un tipo inferido, porque la validación no depende de metadatos de runtime sino del esquema que pasas al decorador.

    Tengo el ValidationPipe puesto y la validación en NestJS no salta. ¿Qué reviso?

    Por orden. Que el DTO sea una clase y que el tipo del parámetro en el controlador sea exactamente esa clase, no any ni un union. Que emitDecoratorMetadata y experimentalDecorators estén a true en tu tsconfig.json, porque sin ellos no hay metadatos de tipo que leer.

    Después, que el pipe esté registrado donde crees: si lo pusiste con APP_PIPE en un módulo de feature en lugar del root, solo aplica a ese ámbito. Y si lo que pasa es que un objeto entero se cuela sin validarse, mira forbidUnknownValues, que Nest fuerza a false cuando no lo declaras tú.

    ¿class-validator sigue mantenido en 2026?

    Sí, aunque a ritmo irregular. La versión actual es la 0.15.1, del 26 de febrero de 2026, publicada justo un día después de la 0.14.4. El bache serio fueron los dieciséis meses entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025); desde ahí ha vuelto a publicar con regularidad. Sigue en 0.x diez años después de su primera versión, lo cual dice bastante sobre su compromiso de estabilidad de API.

    El problema mayor es class-transformer, su dependencia inseparable: última versión 0.5.1, noviembre de 2021. Toda la lógica de @Type, @Transform y transform: true corre sobre un paquete que lleva casi cinco años sin release. No es motivo para migrar mañana, sí para tenerlo en cuenta al empezar un proyecto nuevo.

    ¿Dónde valido las reglas de negocio: en el DTO o en el servicio?

    En el DTO va todo lo que es forma: tipos, formatos, longitudes, rangos, campos requeridos, estructura de los objetos anidados. Son reglas que se responden mirando solo el payload.

    En el servicio va todo lo que necesita contexto: si este usuario puede hacer esta operación, si el stock alcanza, si el pedido está en un estado que admite ese cambio. La prueba rápida es preguntarte si la regla depende de quién hace la petición o del estado actual del sistema. Si depende, no es validación de entrada, es lógica de dominio, y meterla en un decorador te va a complicar los tests.

    ¿Merece la pena migrar un proyecto grande de class-validator a Zod?

    Rara vez de golpe, y casi nunca por el argumento de que "está más moderno". Rehacer DTOs, mapped types, validadores con inyección de dependencias y la integración con Swagger son semanas de trabajo sin una sola feature nueva para el usuario.

    Lo que sí funciona es la convivencia. Nest 12 mantiene el flujo de class-validator plenamente soportado junto al de Standard Schema, así que escribes con Zod lo nuevo y dejas lo existente como está. Se migra por presión real —un bug de sincronía entre tipo y validación, un esquema que necesitas compartir con el frontend— y no por calendario.


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

  • Self-healing code en agentes TypeScript: el bucle que sí corrige

    Self-healing code en agentes TypeScript: el bucle que sí corrige

    El self-healing code en agentes de TypeScript es un patrón sencillo de describir y fácil de implementar mal. Te cuento primero cómo me enteré.

    Un agente mío se pasó tres minutos razonando una tarea, escribió ochenta líneas de TypeScript, llamó a la API interna y devolvió el objeto con userId donde el schema pedía id.

    Una palabra.

    El pipeline hizo lo que hacen todos: lanzó la excepción, abortó con código de salida 1 y me mandó un aviso para que abriera el editor y cambiara esa palabra a mano.

    Lo absurdo es que Zod ya sabía exactamente qué había fallado. Sabía el campo, el tipo recibido, el tipo esperado y la ruta dentro del objeto. Tenía el diagnóstico completo escrito en una estructura de datos. Y con todo eso en la mano, el sistema decidió despertar a un humano.

    Así que monté el bucle de autocorrección. Y durante dos semanas no funcionó, gastando el doble de llamadas al modelo, por un motivo que no vi hasta que abrí el objeto de error con el debugger.

    Resumen rápido:

    • El self-healing convierte el diagnóstico de un verificador determinista en el contexto del siguiente intento, en vez de escalar a un humano.
    • Si usas generateObject del Vercel AI SDK, el detalle del fallo no está en error.message — está en error.cause. Ese es el error que arruina la mayoría de implementaciones.
    • Techo de dos intentos totales, y cuenta intentos, no reintentos.
    • No todos los errores son curables: los de tipos y schema sí, los de credenciales o herramienta caída no.

    Qué es el self-healing code (y qué no es)

    El self-healing code es un patrón en el que el sistema que genera —código o datos estructurados— ejecuta un verificador determinista, captura el diagnóstico exacto del fallo y lo reinyecta como contexto en un reintento acotado, en lugar de tratar el fallo como terminal.

    La idea de fondo: el validador es el mejor prompt que vas a escribir en tu vida, porque es el único que describe el fallo con precisión de campo y sin ambigüedad.

    El bucle tiene cinco pasos:

    1. Generar. El modelo produce el objeto o el código.
    2. Verificar. Zod, tsc o el test runner dictaminan. Sin intervención humana y sin LLM de por medio: determinista.
    3. Extraer el diagnóstico real. Campo, ruta, tipo esperado, tipo recibido. Aquí es donde falla casi todo el mundo.
    4. Reinyectar. El diagnóstico vuelve como turno nuevo de la conversación, junto a la salida anterior.
    5. Acotar. Techo de intentos y salida limpia cuando se agota.

    Conviene separarlo de dos patrones vecinos con los que se confunde.

    No es un retry con backoff. El backoff reintenta lo mismo esperando que el mundo cambie: que se descongestione la red, que el proveedor se recupere. El self-healing reintenta algo distinto, porque le has añadido información que antes no estaba. Si reintentas idéntico un fallo de validación, el modelo suele reproducir el mismo error.

    No es un circuit breaker. El breaker existe para dejar de insistir cuando una herramienta externa lleva minutos caída; lo conté en circuit breaker para agentes IA. Son capas distintas: el breaker mira la salud de un servicio externo, el self-healing mira la forma de lo que devuelve el modelo. En un agente serio acaban conviviendo.

    Y una frontera más: este post va del bucle. De cómo validar y tipar la respuesta en sí ya escribí en cómo tipar las respuestas de una LLM con Zod y TypeScript. Si no tienes esa parte montada, empieza por ahí y vuelve.


    El error que hace que tu bucle de autocorrección no sirva de nada

    Aquí está lo que me costó dos semanas.

    Cuando usas generateObject del Vercel AI SDK y el modelo devuelve algo que no valida, el SDK lanza un NoObjectGeneratedError. La reacción natural es esta:

    catch (error: any) {
      prompt = `Tu respuesta anterior falló con este error: ${error.message}`;
    }
    

    Ese código se ejecuta sin romperse, el bucle gira, gastas otra llamada al modelo y parece que el patrón funciona.

    No funciona. El message de un NoObjectGeneratedError es genérico —del tipo "No object generated"— y no lleva el campo, ni el tipo esperado, ni la ruta. Le estás diciendo al modelo "lo has hecho mal" y esperando que adivine el qué.

    El detalle está en otras propiedades del error, documentadas en el propio AI SDK:

    • error.cause — el error subyacente real: el ZodError con sus issues, o el fallo de parseo de JSON.
    • error.text — el texto crudo que el modelo llegó a generar, que le permite ver su propia salida y compararla con el diagnóstico.
    • error.finishReason — si vale 'length', el JSON no es inválido por confusión del modelo: está truncado porque se acabaron los tokens. Reintentar con el mismo límite es tirar dinero; ahí toca subirlo o partir el schema.

    Ese último matiz es la diferencia entre un bucle que corrige y un bucle que solo encarece la factura.

    Hay un segundo fallo igual de común, y es de contexto. Si en el reintento reasignas el prompt en vez de acumular la conversación, el modelo recibe "tu respuesta anterior falló, corrígela" sin la tarea original y sin su propia salida. generateObject no guarda historial: cada llamada es independiente. El modelo no sabe qué tenía que generar ni qué generó. No hay nada que corregir.


    Implementación del bucle en TypeScript

    Con eso claro, el bucle queda así. Versiones: AI SDK 5 y Zod 4.

    import { generateObject, NoObjectGeneratedError } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    
    const PaymentConfigSchema = z.object({
      customerId: z.string().min(5).describe("ID del cliente, prefijo cus_"),
      amountInCents: z.number().int().positive().describe("Importe en céntimos, nunca decimal"),
      currency: z.enum(["EUR", "USD"]),
      maxPaymentRetries: z.number().int().min(1).max(5),
    });
    
    type PaymentConfig = z.infer<typeof PaymentConfigSchema>;
    
    // Intentos TOTALES, no reintentos: 2 = la primera llamada y una corrección.
    const MAX_ATTEMPTS = 2;
    
    export async function generateSelfHealingConfig(
      userRequirement: string,
    ): Promise<PaymentConfig> {
      const messages: Array<{ role: "user" | "assistant"; content: string }> = [
        { role: "user", content: `Genera la configuración de pago para: "${userRequirement}"` },
      ];
    
      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        try {
          const { object } = await generateObject({
            model: anthropic("claude-opus-5"),
            schema: PaymentConfigSchema,
            messages,
            // Clave: el maxRetries del SDK son reintentos de TRANSPORTE (429, 5xx)
            // y vale 2 por defecto. Sin ponerlo a 0, cada vuelta de este bucle
            // puede disparar hasta 3 peticiones HTTP: 6 llamadas en el peor caso.
            maxRetries: 0,
          });
          return object;
        } catch (error) {
          if (!NoObjectGeneratedError.isInstance(error)) throw error; // 401, red, bugs propios
    
          // Truncado por tokens: reintentar igual no arregla nada.
          if (error.finishReason === "length") {
            throw new Error(
              "[SELF-HEALING] Respuesta truncada por límite de tokens: súbelo o parte el schema.",
            );
          }
    
          if (attempt === MAX_ATTEMPTS) {
            throw new Error(
              `[SELF-HEALING] Sin corregir tras ${MAX_ATTEMPTS} intentos:\n${formatIssues(error.cause)}`,
            );
          }
    
          // El feedback útil: su salida + el diagnóstico concreto, como turno nuevo.
          messages.push({ role: "assistant", content: error.text ?? "(sin salida)" });
          messages.push({
            role: "user",
            content:
              `Tu respuesta no cumple el schema:\n${formatIssues(error.cause)}\n\n` +
              `Corrige ÚNICAMENTE esos campos y devuelve el objeto completo.`,
          });
        }
      }
    
      throw new Error("[SELF-HEALING] Bucle terminado sin resultado");
    }
    
    // El diagnóstico en tres líneas, no el volcado entero.
    function formatIssues(cause: unknown): string {
      if (cause instanceof z.ZodError) {
        return cause.issues
          .map((i) => `· ${i.path.join(".") || "(raíz)"}: ${i.message}`)
          .join("\n");
      }
      return cause instanceof Error ? cause.message : String(cause);
    }
    

    Tres decisiones que no son cosméticas.

    maxRetries: 0. Es la que más gente se salta. El maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte —408, 409, 429 y 5xx— con backoff exponencial. Son compatibles con este bucle, pero se multiplican: dos vueltas tuyas por tres peticiones suyas son seis llamadas donde creías tener dos. Si quieres backoff de red, ponlo tú fuera y controla el total.

    El error vuelve como turno de conversación. Al empujar la salida fallida como mensaje assistant y la corrección como user, el modelo ve su propio intento enfrentado al diagnóstico. Reasignar el prompt original pierde ese contraste — es el segundo fallo que veíamos arriba.

    formatIssues recorta. Un ZodError serializado entero son cientos de caracteres de ruido que pagas en cada vuelta. Las issues mapeadas a ruta: mensaje son las tres líneas que importan.

    Si quieres exprimir la parte del schema —.describe(), uniones discriminadas, enums en vez de strings abiertos— es lo que trabajo en el curso de Zod para TypeScript. Un schema bien diseñado reduce cuántas veces entras en este bucle, que sigue siendo el mejor ahorro disponible.


    Las tres reglas para que esto no se convierta en un bucle infinito caro

    1. Pásale el diagnóstico, no el volcado

    Cinco mil caracteres de stack trace con trazas internas de Node entierran la señal en ruido, y encima los pagas en cada vuelta. Ruta, mensaje y tipo esperado. Nada más.

    2. Techo estricto, y cuenta intentos, no esperanzas

    Dos intentos totales. Si un modelo actual no arregla un fallo de forma teniendo delante el error exacto, el problema casi nunca es el modelo: es un schema que pide algo que el contexto no contiene. El tercer intento no corrige, factura.

    El fallo más común es contar mal. Un for (let i = 1; i <= maxRetries; i++) con maxRetries = 2 da dos intentos totales, es decir, un solo reintento. Si querías dos correcciones, el bucle se te queda corto y no te enteras. Por eso arriba la constante se llama MAX_ATTEMPTS.

    Este techo vive dentro del límite global de pasos del agente, no lo sustituye: sobre eso escribí en el agentic loop en producción.

    3. Corrección quirúrgica, no regeneración

    Pide explícitamente que corrija solo los campos señalados. Si dejas que regenere el objeto entero, es habitual que arregle el campo roto y rompa otro que ya estaba bien — y con techo de dos intentos te quedas sin margen.


    Un oráculo por cada tipo de error

    Zod valida la forma de los datos en runtime. Es la primera capa, no la única: el patrón es idéntico cambiando quién emite el diagnóstico.

    Oráculo Qué detecta Qué le pasas al modelo
    Zod Salida estructurada que no cumple el schema issues mapeadas a ruta: mensaje
    tsc --noEmit Errores de tipos en el código generado Código de error, archivo, línea, tipo esperado vs recibido
    Vitest / Jest Errores de lógica de negocio Nombre del test y el diff esperado/recibido
    ESLint Estilo y patrones prohibidos Nada: esto se arregla con --fix, no con el modelo

    El compilador. Cuando el agente escribe código en vez de devolver datos, tsc --noEmit da diagnósticos con archivo, línea y tipos enfrentados. Un Type '{ userId: string }' is not assignable to type '{ id: string }' es la misma señal que un ZodError: precisa, accionable y gratis. Pásale las líneas del diagnóstico, no la salida completa del compilador — en un proyecto mediano son cientos de líneas y un solo error de tipos suele arrastrar diez mensajes derivados del mismo origen.

    Los tests. El compilador y Zod atrapan errores de forma; los tests atrapan errores de fondo. Un agente que ejecuta la suite, lee qué aserción falló y corrige antes de enseñarte nada es la versión completa del patrón. Es también donde el techo se vuelve innegociable: un agente iterando contra una suite en rojo sin límite es la forma más rápida que conozco de quemar presupuesto. Y hay una trampa propia de esta capa: ejecuta la suite entera antes de aceptar el parche, no solo el test que fallaba. Arreglar el test A rompiendo el B es un resultado muy común y, si solo miras A, lo das por bueno.

    Para montar el entorno donde ese ciclo corre aislado, escribí sobre el test harness para desarrollo con agentes. Y ese salto —de validar datos a montar el ciclo entero de generar, verificar y corregir— es el hilo del curso Construye con IA: de la idea al producto con Claude Code.

    Un apunte de arquitectura: el bucle queda más limpio si los fallos ya viajan como datos tipados en lugar de excepciones sueltas, algo que conté en cómo manejar errores en agentes de IA con TypeScript.


    Qué errores son curables y cuáles no

    Aplicar el bucle a todo es peor que no tenerlo. Esta es la tabla que uso para decidir:

    Tipo de fallo ¿Self-healing? Qué hacer
    Error de tipos (tsc) Sí Reinyectar diagnóstico, 1 reintento
    Schema de salida inválido Sí Reinyectar error.cause + la tarea original
    Aserción de test fallida Sí, con cuidado Reinyectar el diff y correr la suite completa
    Respuesta truncada (finishReason: 'length') No Subir el límite de salida o partir el schema
    Lint y formato No Determinista: --fix
    Tool externa 5xx o timeout No Circuit breaker, no reintento
    Credenciales, 401 No Abortar y escalar
    Requisito ambiguo No Humano en el bucle
    Operación con efectos ya aplicados No Idempotencia o compensación

    Ese último merece un párrafo. Si el primer intento escribió en base de datos o llamó a un endpoint de cobro, reintentar no es autocorregir: es duplicar. El bucle solo es seguro mientras la operación no haya salido de tu proceso. Valida primero, ejecuta después.

    Y hay un coste que conviene tener presente: cada vuelta añade la latencia completa de una llamada al modelo y paga de nuevo los tokens del contexto acumulado, que ahora incluye la salida fallida y el diagnóstico. En un flujo interactivo, a veces es mejor devolver el fallo rápido que hacer esperar el doble para acertar. Si quieres saber en qué se te va de verdad el presupuesto, medir el consumo de tokens del agente es el paso previo.


    Cuando el segundo intento también falla

    El techo implica que existe un camino de salida, y ese camino no puede ser una excepción sin contexto que alguien encuentre en un log tres días después.

    Lo que funciona: registrar el fallo con las cuatro piezas que lo hacen reproducible —la tarea original, la salida del modelo, el diagnóstico del verificador y el número de intentos consumidos— y encolarlo. Ese registro sirve para dos cosas distintas. La inmediata, que alguien lo resuelva. La útil a medio plazo, que la cola se convierte en tu mejor fuente de mejoras del schema: cuando ves tres fallos seguidos sobre el mismo campo, el problema no era el modelo.


    Por dónde empezar mañana

    Coge el punto de tu agente donde hoy salta una excepción de validación. Uno solo.

    Añade tres cosas: extrae el error real (error.cause, no error.message), formatéalo a ruta y mensaje, y devuélvelo como turno nuevo con techo de dos intentos y maxRetries: 0. Loguea cuántas veces entra en la segunda vuelta y cuántas sale con éxito.

    Ese ratio es el diagnóstico del diagnóstico. Si entra a menudo y se corrige, tienes un schema mejorable pero un bucle sano. Si entra mucho y no se corrige, tienes un schema imposible: le estás pidiendo al modelo un campo que nadie podría rellenar con el contexto que le das. Y si no entra casi nunca, enhorabuena — tu schema ya hace el trabajo y el bucle es solo la red.

    En Dominicode Labs es donde vamos rodando estos patrones sobre proyectos reales, con las métricas puestas.

    Los sistemas agénticos que aguantan en producción no son los que no se equivocan. Son los que tienen el diagnóstico a mano y saben devolvérselo al modelo antes de despertar a nadie.


    Preguntas frecuentes

    ¿Por qué mi agente no se corrige aunque le paso el error?

    La causa más común es pasar error.message en vez de error.cause. En un NoObjectGeneratedError del Vercel AI SDK, message es un texto genérico que no nombra el campo ni el tipo esperado; el diagnóstico útil vive en error.cause —el ZodError con sus issues— y la salida cruda del modelo en error.text. Con solo message, el bucle gasta llamadas sin darle al modelo nada con lo que corregir.

    ¿En qué se diferencia el self-healing code de un retry con backoff?

    En qué cambia entre un intento y el siguiente. El backoff reintenta la misma petición esperando que se recupere algo externo —red, proveedor, rate limit— y por eso funciona con fallos transitorios. El self-healing modifica la entrada: añade al contexto el diagnóstico que provocó el fallo. Ante un error de schema, el backoff solo repite el mismo error más despacio.

    ¿No reintenta ya generateObject por su cuenta con maxRetries?

    No de esta forma, y conviene ponerlo a 0. La opción maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte: errores de red y respuestas de API reintentables (408, 409, 429, 5xx) con backoff exponencial. Un fallo de validación de schema no entra ahí, se propaga como NoObjectGeneratedError. Si lo dejas por defecto, cada vuelta de tu bucle puede disparar hasta tres peticiones HTTP.

    ¿Cuántos intentos debería permitir?

    Dos totales: la llamada inicial y una corrección. Con el error exacto delante, un modelo actual corrige los fallos de forma en el primer reintento o no los corrige. Un tercero rara vez cambia el resultado y multiplica coste y latencia. Y cuenta intentos, no reintentos: un bucle i <= 2 da una sola corrección, y es donde más gente se equivoca al implementarlo.

    ¿Se puede hacer self-healing solo con el compilador, sin Zod?

    Sí, y son capas complementarias. tsc --noEmit cubre el código que el agente escribe; Zod cubre la salida estructurada que el modelo devuelve. Si tu agente genera archivos, el compilador es tu oráculo principal. Si devuelve objetos que tu aplicación consume, lo es Zod. Muchos agentes acaban usando los dos en puntos distintos del flujo.

    ¿Sirve para errores de lógica o solo para errores de tipos?

    Sirve para los de lógica, pero cambiando el oráculo: ahí el que dictamina es el test runner, y el diagnóstico que reinyectas es la aserción fallida con su diff esperado/recibido. La diferencia práctica es el riesgo. Un error de tipos tiene una única corrección posible; un test rojo admite varias, y alguna rompe otra cosa. Por eso en esta capa se ejecuta la suite completa antes de aceptar el parche.

    ¿Qué hago si el agente rompe otro test al arreglar el primero?

    Tratarlo como un fallo del intento, no como un éxito parcial. Si el criterio de aceptación es solo el test que fallaba, el bucle acepta parches que degradan el código. El criterio tiene que ser la suite entera en verde; si el parche pone A en verde y B en rojo, se descarta y se consume intento. Con techo de dos, eso normalmente significa escalar — que es la respuesta correcta.

    ¿Es seguro autocorregir una operación que ya escribió en base de datos?

    No. Si el intento fallido tuvo efectos externos, el reintento los duplica. El bucle es seguro mientras la operación no haya salido de tu proceso: valida primero, ejecuta después. Si el efecto ya ocurrió, lo que necesitas es idempotencia o compensación, no autocorrección.


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

  • Diseñar schemas Zod para LLM: tu schema ya es el prompt

    Diseñar schemas Zod para LLM: tu schema ya es el prompt

    Hace unas semanas revisé el pipeline de extracción de facturas de un cliente. Fallaba en uno de cada seis documentos.

    El equipo ya había subido los reintentos a tres, puesto temperature: 0 y cambiado a un modelo más caro. Seguía fallando.

    Miré el schema de Zod: 31 campos, ninguno con .describe(), ocho z.string() donde solo cabían cuatro valores posibles y cuatro .optional() colocados ahí porque "a veces la factura no lo trae".

    El problema no era el modelo. Era el schema. Diseñar schemas Zod para LLM no consiste en describir la forma de tus datos: consiste en escribir instrucciones que el modelo lee antes de responder.

    Y esa es la parte que casi nadie aprovecha.


    El schema de Zod no espera al final: se envía al LLM dentro del prompt

    Cuando usas structured outputs o tool calling, tu schema de Zod no se queda esperando en el servidor a que llegue el JSON. Se convierte a JSON Schema y se envía al modelo en la misma petición.

    En Zod 4 puedes ver exactamente lo que sale de tu código:

    import * as z from "zod";
    
    const Factura = z.object({
      esValida: z.boolean(),
      tipo: z.string(),
      importe: z.number(),
    });
    
    console.log(z.toJSONSchema(Factura));
    

    Eso es literalmente lo que viaja. Y en el caso de Anthropic la documentación del system prompt de tool use lo enseña sin rodeos: cuando llamas a la API con el parámetro tools, el sistema construye un system prompt que incluye tus definiciones tal cual.

    In this environment you have access to a set of tools you can use to answer the user's question.
    ...
    Here are the functions available in JSONSchema format:
    {{ TOOL DEFINITIONS IN JSON SCHEMA }}
    

    Tus nombres de campo, tus tipos, tus descripciones: todo eso son tokens de entrada que el modelo lee antes de generar el primer carácter. Es la misma idea que ya expliqué al hablar de function calling tipado en TypeScript, pero llevada al extremo.

    Si el schema es texto en el prompt, entonces un schema mal escrito es un prompt mal escrito. Y no hay reintento que arregle eso.


    z.string() es un campo abierto. z.enum() es una pregunta cerrada

    Cambiar z.string() por z.enum() es el ajuste con mejor relación esfuerzo/resultado de toda la lista: z.string() deja el espacio de respuesta abierto y z.enum() lo cierra a una lista finita de valores. Es lo primero que toco cuando un pipeline de extracción falla.

    // El modelo puede escribir lo que le dé la gana
    tipoDocumento: z.string(),
    
    // El modelo solo puede elegir
    tipoDocumento: z.enum(["factura", "abono", "recibo", "presupuesto"]),
    

    La diferencia en el JSON Schema generado es esta:

    { "type": "string", "enum": ["factura", "abono", "recibo", "presupuesto"] }
    

    Con z.string() le pides al modelo que invente una etiqueta. Va a devolver "Factura", "FACTURA", "factura simplificada" y "invoice" según el día. Tu Zod lo aceptará todo, porque son strings válidos, y el error aparecerá tres capas más abajo cuando alguien haga un switch.

    Con z.enum() el espacio de respuesta está cerrado. Además, cuando el proveedor aplica el modo estricto, el enum se traduce en una restricción real de decodificación: el modelo no puede emitir un valor fuera de la lista.

    Regla práctica: si en tu cabeza el campo tiene una lista de valores, escríbela en el schema. Si te da pereza escribirla, es que tampoco la tenías clara tú.


    .describe(): el campo que casi nadie usa y que sí llega al modelo

    .describe() es el método de Zod que más impacto tiene en la precisión de un LLM y el que casi nadie usa: el texto que le pasas acaba en la clave description del JSON Schema que recibe el modelo, no se queda en tu editor.

    fechaVencimientoISO: z
      .string()
      .describe(
        "Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). " +
        "Es la fecha de vencimiento, NO la de emisión. " +
        "Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
      ),
    

    No es decorativo. Compruébalo con z.toJSONSchema():

    {
      "type": "string",
      "description": "Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). Es la fecha de vencimiento, NO la de emisión. Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
    }
    

    Ese description viaja con el schema. En Zod 4, .describe("texto") es equivalente a .meta({ description: "texto" }) y todos los metadatos se copian al JSON Schema resultante.

    La documentación de Anthropic sobre definición de herramientas es tajante al respecto: "Provide extremely detailed descriptions. This is by far the most important factor in tool performance". No es un detalle de estilo. Es el sitio donde metes las reglas de negocio que el nombre del campo no puede expresar.

    Dos avisos prácticos:

    1. La documentación del Vercel AI SDK recomienda encadenar .describe() o .meta() al final de la cadena, porque la mayoría de métodos de Zod devuelven una instancia nueva que no hereda los metadatos. En mis pruebas con z.toJSONSchema() y Zod 4.5.4 (agosto de 2026) la descripción sobrevivía también antes de .optional(), pero son dos caminos de código distintos y seguir la recomendación no cuesta nada.
    2. Cada descripción son tokens que pagas en cada llamada. Describe los campos ambiguos, no los obvios: nombreCliente no necesita párrafo.

    Si quieres dominar la parte de Zod que no es "poner z.string() y seguir", en mi curso de Zod para validación y transformación de datos en TypeScript trabajo esto con schemas reales de producción.


    .optional() es un agujero negro. Dale una salida explícita

    .optional() es el error que más alucinaciones fabrica en un schema pensado para un LLM: saca el campo de required sin dejar ninguna señal de cuándo debe omitirse, y el modelo rellena el hueco.

    Piensa qué ve el modelo cuando marcas un campo como opcional. Este schema:

    z.object({
      importeEUR: z.number().nullable(),
      nota: z.string().optional(),
    });
    

    produce esto:

    {
      "type": "object",
      "properties": {
        "importeEUR": { "type": ["number", "null"] },
        "nota": { "type": "string" }
      },
      "required": ["importeEUR"],
      "additionalProperties": false
    }
    

    Fíjate en nota. Desaparece de required y no queda ninguna otra señal. El modelo no recibe ninguna pista sobre cuándo debe omitirlo ni qué significa su ausencia. Tú sabes que "ausente" quiere decir "no aplica", pero eso está en tu cabeza, no en el prompt.

    importeEUR, en cambio, sigue siendo obligatorio y declara null como valor legítimo. El modelo tiene un camino explícito para decir "esto no está".

    Esto además encaja con cómo funcionan los structured outputs estrictos de OpenAI, donde todos los campos deben ir en required y la forma documentada de emular un opcional es un tipo unión con null manteniendo el campo obligatorio.

    Y hay una versión todavía mejor cuando el "no lo sé" tiene matices:

    // Mal: el modelo se inventa una fecha para rellenar el hueco
    fechaVencimientoISO: z.string(),
    
    // Regular: puede omitirlo, pero no sabe cuándo
    fechaVencimientoISO: z.string().optional(),
    
    // Bien: el "no sé" es una respuesta válida y tipada
    vencimiento: z.discriminatedUnion("estado", [
      z.object({ estado: z.literal("presente"), fechaISO: z.string() }),
      z.object({ estado: z.literal("no_aplica") }),
      z.object({ estado: z.literal("ilegible") }),
    ]),
    

    Un modelo obligado a rellenar un campo que no puede saber rellena igual. Eso no es un bug del modelo, es la consecuencia directa de por qué la IA se inventa cosas: si el schema no ofrece una salida honesta, la salida más probable es una plausible. Diséñale la puerta de "no lo sé" y la usará.


    Uniones discriminadas en Zod: dale un mapa al modelo, no un test de opción múltiple

    Con z.union(), el JSON Schema resultante es un anyOf de objetos sin nada que los distinga:

    // salida recortada de z.toJSONSchema()
    { "anyOf": [ { "properties": { "importeEUR": ... } }, { "properties": { "motivo": ... } } ] }
    

    El modelo tiene que deducir cuál encaja comparando formas. Es una decisión difusa.

    Con z.discriminatedUnion() cambia la estructura:

    const Movimiento = z.discriminatedUnion("tipo", [
      z.object({ tipo: z.literal("pago"), importeEUR: z.number() }),
      z.object({ tipo: z.literal("reembolso"), motivo: z.string() }),
    ]);
    

    Sale un oneOf en el que cada rama lleva { "type": "string", "const": "pago" } en el discriminador. El modelo primero elige una etiqueta —decisión de un token, con opciones cerradas— y a partir de ahí la forma del resto del objeto queda determinada.

    Es la misma razón por la que las uniones discriminadas nos gustan en TypeScript: convierten una inferencia estructural en una decisión explícita. Solo que aquí quien se beneficia del narrowing no es el compilador, es el modelo.

    Un aviso importante antes de que lo copies: en el modo estricto de OpenAI la raíz del schema tiene que ser un objeto, y la documentación es explícita en que un objeto raíz no puede ser del tipo anyOf. Así que no mandes la unión suelta como en el ejemplo de arriba: anídala dentro de un objeto raíz, como el campo vencimiento de la sección anterior.

    Y un detalle más: z.toJSONSchema() emite oneOf para las uniones discriminadas, mientras que la lista de tipos soportados de OpenAI habla de anyOf. Si vas contra structured outputs, pasa por el helper de su propio SDK (zodResponseFormat de openai/helpers/zod) en lugar de por z.toJSONSchema() directo. Contra tool use de Anthropic no tienes esta restricción.


    El orden de los campos no es cosmética

    Un LLM genera tokens en orden. Si el primer campo de tu objeto es la conclusión, la conclusión se escribe antes de que exista ningún razonamiento en el contexto.

    Y el orden lo pones tú. La documentación de structured outputs es explícita: la salida se produce en el mismo orden en que están las claves del schema que envías. Si quieres cambiar el orden, cambias el schema.

    // Mal: decide primero y justifica después
    const TriajeMal = z.object({
      prioridad: z.enum(["alta", "media", "baja"]),
      evidencia: z.string(),
    });
    
    // Bien: reúne evidencia, luego concluye
    const TriajeBien = z.object({
      evidencia: z
        .string()
        .describe("Cita literal del ticket que justifica la prioridad."),
      senalesRiesgo: z.array(z.enum(["caida_servicio", "perdida_datos", "cliente_enterprise"])),
      prioridad: z.enum(["alta", "media", "baja"]),
    });
    

    No es una teoría mía. El ejemplo canónico de razonamiento matemático de la propia documentación de OpenAI pone steps antes de final_answer. El schema es la plantilla del razonamiento, no solo del resultado.

    Lo mismo aplica a los nombres. date no dice nada; fechaVencimientoISO dice qué fecha es y en qué formato la quieres. El nombre del campo es contexto gratis: no lo desperdicies en abreviaturas.

    Y sobre la profundidad: los schemas planos aciertan más. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite técnico no te va a frenar nunca. El que importa es otro: mucho antes de acercarte a esa cifra ya notarás que un objeto de cuatro niveles produce más fallos que dos llamadas con dos schemas planos.


    Dónde termina el diseño del schema y empieza la validación con Zod

    Nada de esto elimina la validación. Un schema bien diseñado reduce los fallos en origen; no los lleva a cero, y sigues necesitando safeParse, reintentos y logging.

    Esa es exactamente la frontera: este post va de lo que ocurre antes de la llamada. Lo que ocurre después —parseo seguro, limpieza defensiva, reintentos con contexto del error— lo tienes desarrollado en cómo tipar las respuestas de una LLM con Zod y TypeScript.

    Ni siquiera tienen que ser el mismo schema. Manda al modelo uno plano y con enums, y transfórmalo después a tu modelo de dominio con las utilidades genéricas de tus wrappers.


    Resumen: qué lee el modelo en cada caso

    Lo que escribes en Zod Lo que lee el modelo Cuándo usarlo
    z.string() campo de texto libre, sin restricción solo texto genuinamente libre
    z.enum([...]) "enum": ["a","b"] — lista cerrada cualquier campo con valores finitos
    .describe("...") "description": "..." — instrucción del campo campos ambiguos o con regla de negocio
    .optional() el campo desaparece de required, sin más señal casi nunca en schemas para LLM
    .nullable() "type": ["string","null"] y sigue en required cuando "no hay dato" es respuesta válida
    z.discriminatedUnion() oneOf con const en el discriminador cuando el "no lo sé" tiene matices

    Qué puedes cambiar hoy

    Abre el schema que tengas en producción y haz estas cinco pasadas. Te llevará veinte minutos:

    1. Ejecuta z.toJSONSchema(tuSchema) y lee la salida. Ese texto es tu prompt. Si te resulta ambiguo a ti, imagina al modelo.
    2. Convierte a z.enum() todo z.string() que tenga una lista finita de valores.
    3. Añade .describe() solo a los campos ambiguos, con la regla de negocio y el formato exacto.
    4. Sustituye cada .optional() por .nullable() o por una rama explícita de "desconocido" en una unión discriminada.
    5. Mueve la conclusión al final y pon delante los campos de evidencia.

    En el pipeline de facturas del principio no hizo falta tocar el modelo ni subir más los reintentos: el campo que más fallaba era una fecha obligatoria que el documento a veces no traía, y pasó de inventarse valores a declarar ilegible en cuanto dejó de ser un string a secas.

    Si además estás montando el pipeline entero —schema, llamada, validación y reintentos— eso es justo lo que construimos paso a paso en el curso Construye con IA: de la idea al producto, y en Dominicode Labs revisamos schemas reales de proyectos de la comunidad.

    La conclusión que quiero que te lleves es una sola: deja de tratar el schema como un portero que revisa la salida del modelo y empieza a tratarlo como la última instrucción que el modelo lee antes de contestar. Cambia el diseño y dejarás de necesitar tantos reintentos.


    Preguntas frecuentes

    ¿El modelo lee de verdad el .describe() de mis campos?

    Sí. .describe() se traduce a la clave description del JSON Schema, y ese JSON Schema es lo que se envía al proveedor junto con la petición. En el caso de Anthropic, la documentación muestra que las definiciones de herramientas se insertan en el system prompt en formato JSON Schema. Puedes comprobar exactamente qué se envía ejecutando z.toJSONSchema() sobre tu schema.

    ¿Usar .optional() está mal siempre?

    No, pero casi nunca es lo que quieres cuando el schema va a un modelo. .optional() hace que el campo desaparezca de required sin dejar ninguna señal sobre cuándo omitirlo. Con .nullable() el campo sigue siendo obligatorio y null es una respuesta explícita. Además, el modo estricto de structured outputs de OpenAI exige que todos los campos estén en required y documenta la unión con null como la forma de emular un opcional.

    ¿Structured Outputs elige el valor correcto o solo el formato correcto?

    Garantiza que la estructura encaje con el schema, no que el contenido sea correcto. Un modelo puede devolver una fecha con formato válido y valor inventado, o elegir el enum equivocado. Diseñar bien el schema mejora el acierto semántico; validar después sigue siendo obligatorio.

    ¿Cuántos campos debería tener un schema para un LLM?

    Menos de los que crees. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite que importa no es el técnico sino el de acierto: la degradación empieza muchísimo antes. Si tu schema pasa de veinte campos o de dos niveles, casi siempre sale mejor partirlo en dos llamadas con schemas planos que insistir en una sola extracción gigante.

    ¿Esto aplica igual con el Vercel AI SDK?

    Sí. generateObject y las definiciones de tools convierten internamente tu schema de Zod a JSON Schema con el helper zodSchema, así que las mismas reglas de diseño aplican. La documentación del SDK recomienda además encadenar .describe() o .meta() al final de la cadena para asegurar que los metadatos acaben en el JSON Schema generado.


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

  • Evals deterministas para agentes de IA: testea datos, no frases

    Evals deterministas para agentes de IA: testea datos, no frases

    Un developer me enseñó su suite de tests para un agente de soporte. Tenía esta línea:

    expect(result.text).toBe("Tu suscripción ha sido cancelada con éxito.");
    

    En local pasó tres veces. Hizo push. En la cuarta ejecución en CI, el modelo contestó: "Hemos procesado la cancelación de tu suscripción correctamente."

    Pipeline en rojo. La suscripción se canceló. La tool correcta se llamó con el userId correcto. El agente hizo su trabajo y el test falló porque el modelo cambió tres palabras.

    Ese test no medía al agente. Medía la redacción de un modelo probabilístico, justo la parte que no controlas. La salida son los evals deterministas para agentes de IA: en vez de relajar la aserción hasta que ya no garantice nada, cambias lo que el agente devuelve.


    ¿Qué son los evals deterministas para agentes de IA?

    Un eval determinista es una comprobación cuyo resultado no depende de cómo redacte el modelo. El agente no devuelve una frase: devuelve un objeto tipado —un veredicto— y el test asierta de forma exacta sobre sus campos. Un decision que es un enum cerrado, un array de códigos de motivo, un identificador. Datos, no prosa. La misma clase de aserción que harías contra un endpoint REST.

    La diferencia con lo que la mayoría llama "eval" es el punto de aplicación. No estás puntuando una respuesta a posteriori con una rúbrica: estás rediseñando la interfaz del agente para que su decisión sea inspeccionable.

    Conviene marcar la frontera con dos cosas que ya conté por separado. El test harness para agentes de IA es el entorno: las tools falsas, el presupuesto de tokens que corta, el timeout real, la traza reproducible. Es el paso previo y es obligatorio. Este post va de lo otro: qué afirmas dentro de ese entorno.

    Y el function calling tipado con TypeScript valida la ENTRADA: los argumentos que el modelo manda a una tool, que en ai@7 viajan en inputSchema. Aquí hablamos de la SALIDA: el veredicto que emite el agente. Es la otra punta del mismo cable, y casi nadie tipa esa punta.


    Los dos callejones sin salida antes de llegar aquí

    Cuando el test de arriba se pone rojo hay dos salidas habituales, y las dos son peores que el problema: relajar la aserción hasta que deje de garantizar nada, o delegar el juicio en otro modelo.

    El primero es relajar la aserción. Un toContain, una expresión regular, un .toLowerCase().includes(). Queda así:

    expect(result.text.toLowerCase()).toContain("cancel");
    

    Verde. Y ahora ese test pasa también si el agente respondió "No puedo cancelar tu suscripción, contacta con soporte". Acabas de escribir una aserción que da verde cuando el agente hace exactamente lo contrario de lo que le pediste. Un test que no puede fallar en el caso que importa no es un test: es decoración en el pipeline.

    El segundo es montar un LLM-as-a-Judge para todo. Otro modelo lee la respuesta y decide si es correcta. Funciona, pero paga tres precios: es lento (una llamada extra por caso), es caro (y los evals se ejecutan por lotes, así que multiplica), y sobre todo hereda el no-determinismo que intentabas eliminar. Tu suite pasa a depender de que el juez opine igual el martes que el jueves. Y entonces tienes un segundo problema: quién calibra al juez.

    El juez tiene su sitio. Pero es el último recurso, no el primero. Antes de delegar una decisión en otro modelo, pregúntate si esa decisión se puede tipar. La mayoría de las veces se puede.

    Superficie de aserción Determinista Coste Cuándo usarla
    Texto libre con toBe o regex No Cero Nunca sobre la salida del modelo: o revienta con sinónimos o da verde con cualquier cosa
    Objeto tipado (generateObject + Zod) Sí en la aserción Cero extra Siempre que la salida sea una decisión, una clasificación, una extracción o un enrutado
    LLM-as-a-Judge No Alto, una llamada por caso Cuando la calidad es irreductiblemente textual: resúmenes, tono, redacción, código

    El giro: que la decisión sea un dato, no una frase

    Si quieres afirmar sobre la decisión del agente, haz que la decisión sea un campo.

    Con el AI SDK de Vercel eso es generateObject más un schema de Zod. Los ejemplos de este post corren con ai@7, zod@4 y Vitest 4, versiones de septiembre de 2026. El modelo deja de tener libertad de formato: o devuelve algo que valida contra el schema, o falla ruidosamente, que también es información útil.

    Un agente que revisa solicitudes de reembolso:

    // refund-agent.ts
    import { generateObject } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    import type { RefundTicket } from "./types";
    import { REFUND_POLICY_PROMPT } from "./prompts";
    
    const model = anthropic("claude-haiku-4-5-20251001");
    
    export const RefundVerdictSchema = z.object({
      decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
      reasonCodes: z
        .array(
          z.enum([
            "OUTSIDE_RETURN_WINDOW",
            "ITEM_DAMAGED_BY_CUSTOMER",
            "DUPLICATE_REQUEST",
            "OPEN_CHARGEBACK",
            "HIGH_VALUE_ORDER",
            "TRUSTED_CUSTOMER",
          ]),
        )
        .min(1),
      riskSignals: z.object({
        priorRefunds12m: z.number().int().min(0),
        daysSincePurchase: z.number().int().min(0),
      }),
      summary: z.string(),
    });
    
    export type RefundVerdict = z.infer<typeof RefundVerdictSchema>;
    
    export async function reviewRefund(ticket: RefundTicket): Promise<RefundVerdict> {
      const { object } = await generateObject({
        model,
        schema: RefundVerdictSchema,
        temperature: 0,
        instructions: REFUND_POLICY_PROMPT,
        prompt: JSON.stringify(ticket),
      });
    
      return object;
    }
    

    Fíjate en lo que acaba de pasar. reviewRefund ya no devuelve texto: devuelve RefundVerdict. Un tipo. Tu test vuelve a ser un test normal.

    Si ese veredicto es el paso final de un bucle con varias herramientas por medio, el schema es el punto de salida del bucle. Cómo montarlo con estado y reintentos lo desarrollé en el agentic loop en producción con TypeScript.

    Hasta aquí es lo que cuenta todo el mundo. Lo que casi nadie cuenta es que el schema puede estar bien tipado y ser una superficie de test pésima.


    Cómo diseñar el schema del veredicto: 5 reglas

    Esta es la parte que decide si tu suite aguanta seis meses o se convierte en ruido. Cinco reglas.

    1. Enums cerrados, nunca strings libres

    decision: z.string() valida perfectamente y no te sirve de nada. El modelo devolverá "rechazado", luego "Rechazado por política", luego "REJECT". Has movido el problema del texto de la respuesta al texto de un campo.

    // Mal: sigues asertando sobre prosa
    decision: z.string(),
    
    // Bien: el espacio de valores es finito y conocido
    decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
    

    Un enum cerrado tiene una propiedad que ningún string tiene: si el modelo quiere decir algo, solo puede decirlo de una manera. Ahí es donde toBe recupera el sentido.

    2. Códigos de motivo, no explicaciones

    Un veredicto que solo dice REJECTED te deja testear el qué, pero no el porqué. Y el porqué es donde viven las regresiones interesantes: el agente sigue rechazando el caso correcto, pero por el motivo equivocado. Eso es un bug que un test binario no ve.

    Por eso reasonCodes es un z.array(z.enum([...])) y no un z.array(z.string()). Con códigos puedes asertar la causa exacta. Con texto libre, vuelves al principio del post.

    Diseñar bien esa lista de códigos es trabajo de verdad: enums demasiado finos y el modelo elige mal entre opciones casi idénticas; demasiado gruesos y no distinguen nada. Empieza por los motivos que ya aparecen escritos en tu política de negocio.

    3. Los scores numéricos son la aserción más frágil que existe

    confidenceScore: z.number() es tentador. Y es una trampa.

    El modelo devuelve 0.82 hoy y 0.79 mañana con la misma entrada. Cualquier test que compare el valor exacto es un test que parpadea. Y cualquier umbral que escribas dentro del prompt —"si la confianza supera 0.8, aprueba"— es lógica de negocio metida en la parte no determinista del sistema.

    Dos reglas:

    • Si el score se queda, asierta rangos o umbrales, nunca el valor: expect(v.confidenceScore).toBeGreaterThan(0.7).
    • Mejor aún: saca el umbral del modelo y ponlo en tu código. Que el agente devuelva señales en bruto (priorRefunds12m, daysSincePurchase) y que la regla la aplique una función TypeScript pura.
    // route-verdict.ts — 100% determinista, testeable sin llamar al modelo
    export function routeVerdict(v: RefundVerdict): "AUTO" | "MANUAL_REVIEW" {
      const { priorRefunds12m, daysSincePurchase } = v.riskSignals;
    
      // El veredicto del agente manda: si pidió revisión humana, no la saltamos
      if (v.decision === "MANUAL_REVIEW") return "MANUAL_REVIEW";
      if (priorRefunds12m >= 3) return "MANUAL_REVIEW";
      if (daysSincePurchase > 30 && v.decision === "APPROVED") return "MANUAL_REVIEW";
    
      return "AUTO";
    }
    

    Cada umbral que mueves del prompt a una función es un test que pasa de probabilístico a exacto.

    4. Separa lo que se asierta de lo que se lee

    El schema puede —y suele— tener campos en texto libre. summary está ahí para que un humano entienda la decisión en el panel de revisión, y hace falta.

    La regla es que ese campo no se asierta jamás. Ni con toContain, ni con regex, ni "solo para comprobar que no viene vacío". Déjalo escrito en un comentario del propio schema, para que el siguiente developer no caiga en la tentación. Un schema tiene dos zonas: la contractual, sobre la que testeas, y la informativa, que solo se lee.

    5. Los campos opcionales fabrican tests frágiles

    En cuanto un campo permite undefined, tu test tiene que decidir qué significa eso. Y normalmente no lo decide: lo esquiva con un ?. y se queda verde por accidente.

    // Ambiguo: ¿no había motivos, o el modelo no los rellenó?
    reasonCodes: z.array(ReasonCode).optional(),
    
    // Explícito: el array siempre viene, y siempre con al menos un motivo
    reasonCodes: z.array(ReasonCode).min(1),
    

    Prefiere valores por defecto, arrays vacíos y uniones discriminadas antes que opcionalidad. Un undefined que atraviesa la suite entera sin que nadie lo asierte es un agujero con forma de test.

    Este tipo de diseño —enums, refinamientos, uniones discriminadas, z.infer para no duplicar tipos— es lo que trabajo paso a paso en el curso de Zod para TypeScript, porque aquí el schema no es validación defensiva: es la superficie de test de todo el sistema.


    El test que resulta

    Con el schema anterior, el eval en Vitest es aburrido. Ese es el objetivo: un test de agente de IA que se lee igual que cualquier otro test de tu suite.

    // refund-agent.eval.test.ts
    import { describe, it, expect } from "vitest";
    import { reviewRefund, type RefundVerdict } from "./refund-agent";
    import { routeVerdict } from "./route-verdict";
    import { lateRequestWithChargeback } from "./fixtures";
    
    describe("refund agent · casos obvios", () => {
      it("rechaza una solicitud fuera de plazo con chargeback abierto", async () => {
        const verdict = await reviewRefund(lateRequestWithChargeback);
    
        expect(verdict.decision).toBe("REJECTED");
        expect(verdict.reasonCodes).toContain("OPEN_CHARGEBACK");
        expect(verdict.reasonCodes).toContain("OUTSIDE_RETURN_WINDOW");
        expect(verdict.reasonCodes).not.toContain("TRUSTED_CUSTOMER");
      });
    });
    
    describe("routeVerdict · sin modelo", () => {
      it("escala a revisión manual con 3 reembolsos previos", () => {
        const verdict: RefundVerdict = {
          decision: "APPROVED",
          reasonCodes: ["TRUSTED_CUSTOMER"],
          riskSignals: { priorRefunds12m: 3, daysSincePurchase: 5 },
          summary: "",
        };
    
        expect(routeVerdict(verdict)).toBe("MANUAL_REVIEW");
      });
    });
    

    Dos detalles que importan.

    El toContain de aquí no es el toContain del callejón sin salida. Sobre un string comprueba subcadenas y da verde con cualquier ruido alrededor; sobre un array de enums comprueba pertenencia exacta a un conjunto cerrado. Misma función, garantías opuestas.

    Y el not.toContain vale tanto como el positivo. Un agente que rechaza el caso correcto pero marca al cliente como fiable está acertando por la razón equivocada, y ese es el fallo que se cuela a producción sin que nadie lo vea.

    Este test no se rompe si el modelo cambia la redacción del summary. Ni si cambia el orden de los motivos. Ni si actualizas a la siguiente versión del modelo y escribe más bonito. Solo se pone rojo cuando el agente decide distinto, que es exactamente lo que querías vigilar. Si quieres afinar el diseño de suites, fixtures y aislamiento de dependencias, ese músculo lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library: los ejemplos son de Angular, pero el diseño de suites y fixtures se traslada tal cual.


    Los límites de los evals deterministas en agentes de IA

    Toca ser honesto: el schema hace determinista la aserción, no el modelo.

    temperature: 0 reduce muchísimo la varianza, pero no la elimina. Entre el batching en el servidor, la aritmética en coma flotante y el enrutado interno de los modelos grandes, la misma entrada puede darte una decisión distinta. Menos que antes. No cero.

    La forma de convivir con eso es partir la suite en dos, y esta distinción es la que casi nadie hace.

    Casos obvios. El cliente pide el reembolso de un pedido de hace dos años con un chargeback abierto. Solo hay una respuesta razonable. Estos casos son tests binarios, corren siempre y bloquean el merge. Si uno falla, hay un bug: en el prompt, en el schema o en el modelo que acabas de actualizar.

    Casos de frontera. El pedido tiene 31 días y la política dice 30, pero el cliente lleva cinco años contigo. Aquí ni tú tienes una respuesta única. Estos casos no se testean como binarios: se miden como tasa de acierto. Ejecutas N veces y exiges un umbral de consistencia. Cinco ejecuciones es el mínimo que justifica el coste, no una muestra seria: si el caso importa de verdad, sube a veinte antes de fiarte de la tasa. Por qué N no es un número arbitrario lo desarrollé en evaluaciones automatizadas para agentes.

    // refund-agent.borderline.test.ts
    import { borderlineTicket } from "./fixtures";
    
    async function decisionCounts(runs: number, ticket: RefundTicket) {
      const results = await Promise.all(
        Array.from({ length: runs }, () => reviewRefund(ticket)),
      );
    
      return results.reduce<Record<string, number>>((acc, r) => {
        acc[r.decision] = (acc[r.decision] ?? 0) + 1;
        return acc;
      }, {});
    }
    
    it(
      "mantiene el caso frontera en revisión manual (4 de 5)",
      async () => {
        const counts = await decisionCounts(5, borderlineTicket);
        expect(counts.MANUAL_REVIEW ?? 0).toBeGreaterThanOrEqual(4);
      },
      60_000,
    );
    

    Meter los casos de frontera en la suite que bloquea el merge es la receta perfecta para que el equipo empiece a relanzar pipelines hasta que pasen. Y a partir de ese día los tests dejan de significar nada. Van en un job programado, con su propio umbral y su propia alerta cuando la tasa cae.

    Sí, esta suite cuesta dinero, porque llama al modelo de verdad. Por eso corre por lotes y no en cada push, mientras el test harness con tools falsas sigue corriendo en cada commit.


    Cuándo sí necesitas un LLM-as-a-Judge

    Cuando la calidad de la salida es irreductiblemente textual.

    Si tu agente escribe un resumen, redacta un email a un cliente o genera un módulo entero de código, no hay enum que capture "esto está bien". Ahí el juez —con rúbrica explícita, golden dataset versionado y calibración humana— es la herramienta correcta, y lo desarrollé entero en evals para código generado por IA.

    La regla de reparto es simple: si la decisión se puede tipar, típala; el juez es para lo que sobra después. En la mayoría de agentes de negocio, lo que sobra es mucho menos de lo que parece antes de sentarse a diseñar el schema.


    Por dónde empezar mañana

    Coge un agente. El que más te preocupe.

    Mira qué devuelve hoy. Si devuelve texto, escribe el schema del veredicto: un enum de decisión, un array de códigos de motivo, las señales numéricas en bruto y un summary que no vas a asertar nunca. Cambia la llamada a generateObject. Y mueve al menos un umbral del prompt a una función TypeScript.

    Después escribe cinco casos obvios. Cinco. Con eso ya tienes una red que detecta el día en que cambies de modelo y el agente empiece a aprobar lo que antes rechazaba, que es la regresión que de verdad cuesta dinero.

    Este tipo de decisión de diseño es lo que separa una demo de un producto que aguanta usuarios reales, y es el hilo que sigo en el curso Construye con IA: de la idea al producto con Claude Code. En Dominicode Labs están los schemas y las suites completas de los agentes que corremos en producción, con sus casos de frontera y sus umbrales reales.

    Deja de testear lo que el agente dice. Testea lo que el agente decide.


    Preguntas frecuentes

    ¿Qué es exactamente un eval determinista?

    Es una comprobación automática cuyo resultado no depende de cómo redacte el modelo. Se consigue haciendo que el agente devuelva un objeto tipado en lugar de texto y asertando sobre campos de valores cerrados, como enums o arrays de códigos. La aserción vuelve a ser exacta y repetible, igual que si testearas la respuesta de una API REST.

    ¿Con temperature 0 ya tengo determinismo garantizado?

    No. Reduce mucho la varianza, pero no la elimina, porque hay factores del lado del proveedor que no controlas, como el batching de peticiones o la aritmética en coma flotante. Lo que sí es determinista es tu aserción, y por eso los casos de frontera se miden como tasa de acierto sobre varias ejecuciones en lugar de como un test binario.

    ¿Puedo asertar sobre un campo de confianza numérico?

    Puedes, pero solo por rangos o umbrales, nunca por el valor exacto, porque el mismo caso te dará valores ligeramente distintos entre ejecuciones. La mejor opción es que el modelo devuelva las señales en bruto y que el umbral lo aplique una función de tu código, que sí puedes testear al cien por cien sin llamar al modelo.

    ¿En qué se diferencia esto de un test harness?

    El harness es el entorno de ejecución: las herramientas falsas, el presupuesto de tokens, el timeout y la traza. Responde a si el agente se salió de sus límites. Los evals deterministas son las aserciones que escribes dentro de ese entorno y responden a si el agente decidió lo correcto. Se montan en ese orden: primero el entorno, después las aserciones.

    ¿Estos tests corren en cada push?

    Los que no llaman al modelo, sí: el enrutado, los umbrales y toda la lógica pura alrededor del veredicto. Los que llaman al modelo de verdad cuestan dinero y tardan, así que van en un job programado sobre un conjunto reducido de casos, separando los obvios, que bloquean el merge, de los de frontera, que solo alertan cuando la tasa de acierto cae.


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

  • Bun 1.4.1: tu bundle de zod pesa un 79% menos sin tocar código

    Bun 1.4.1: tu bundle de zod pesa un 79% menos sin tocar código

    Hace unas semanas abrí el análisis de bundle de un backend que despliego como ejecutable compilado desde hace dos años. Hono, Postgres, validación con zod. Nada exótico.

    Lo que me llamó la atención no fue el tamaño total, fue el reparto. Zod se llevaba una porción absurda para los cuatro schemas que usaba de verdad.

    La explicación estaba en un await import() dinámico enterrado en un módulo de rutas. Ese import funcionaba como un muro: el bundler no podía mirar al otro lado, así que metía el módulo entero por si acaso.

    Bun 1.4.1 tira ese muro. El número que publica el equipo de Bun es exactamente el que me interesaba: zod pasa de 375,3 KB a 77,3 KB. Un 79% menos sin tocar una línea de código de aplicación.

    El titular fácil de esta release es "202 issues cerrados". Ese no es el titular. El titular es que el bundler de Bun ha dejado de ser su eslabón flojo.

    Bun 1.4.1 se publicó el 4 de septiembre de 2026 y su cambio principal está en el bundler: bun build ya hace tree-shaking a través de import() dinámico y de export * as. Medido por el equipo de Bun sobre librerías reales, zod 4.5 baja de 375,3 KB a 77,3 KB (79% menos), effect 3.22 de 369,1 KB a 163,6 KB (56% menos) y fp-ts 2.16 de 21,8 KB a 3,2 KB (85% menos). En el runtime llegan HTTP/2 y HTTP/1.1 en el mismo puerto de Bun.serve(), Bun.write() con streaming a disco y crypto.argon2(). Se actualiza con bun upgrade, y las notas no anuncian ningún breaking change: son 202 issues cerrados sobre la 1.4.0.

    Todas las cifras de este post salen de las notas oficiales de la release de Bun v1.4.1.


    Tree-shaking a través de import() dinámico: el gran cambio de Bun 1.4.1

    Hasta ahora un import dinámico era una frontera para el bundler: sabía que el módulo existía, pero no qué se usaba de él, así que la única opción segura era incluirlo entero.

    Mira este caso, sacado de las notas de la release:

    // is.ts exports isNumber, isOdd, and isEven
    const { isOdd } = await import("./is");
    console.log(isOdd(3));
    

    Antes, ese import() arrastraba isNumber e isEven al bundle aunque nadie las llamara nunca. Ahora Bun analiza el destructuring y solo entra isOdd.

    Parece un detalle de juguete. Multiplícalo por una librería con cientos de exports y entiendes el 79% menos de zod.

    Y ahí está lo interesante: los patrones de carga perezosa que usábamos para "no cargar la validación hasta que haga falta" llevaban años penalizándonos. Cargábamos tarde, sí, pero cargábamos todo.

    En el curso de Zod para TypeScript trabajo justo esa parte: componer schemas por módulo en lugar de un barrel gigante que el bundler tiene que adivinar.

    export * as: el otro sitio donde se escondía el peso

    El segundo sitio donde Bun 1.4.1 recorta peso es más silencioso: los re-exports.

    export * as algo from "./modulo" es el re-export de namespace: agrupas un módulo entero bajo un nombre y lo sacas por tu index.ts. Cómodo para importar, veneno para el tamaño final.

    Bun 1.4.1 optimiza ese re-export. El resultado con fp-ts 2.16: de 21,8 KB a 3,2 KB. Un 85% menos.

    Si tus barrels reexportan con export * as —y en arquitectura por features es habitual agrupar así—, este cambio te afecta aunque no lo hayas pedido. Si solo usas export * plano, mide antes de celebrar.

    Code splitting y ejecutables que arrancan antes

    El code splitting también mejora. El dashboard de Medusa pasa de 349 a 245 archivos JS generados: menos peticiones, menos overhead y menos coste de arranque en el navegador.

    Y en builds de navegador con --splitting, Bun ahora emite module preloading, así que los chunks se piden en paralelo en lugar de en cascada.

    La parte que más me sorprendió está en los ejecutables compilados: arrancan alrededor de un 20% más rápido, con el bytecode más compacto según el equipo de Bun. El caso que usan de ejemplo es Claude Code: de 397 ms a 318 ms de arranque, y la instalación baja de 376 MB a 207 MB.

    Ochenta milisegundos escasos suenan a nada hasta que recuerdas cuántas veces al día lanzas tu CLI favorita. Y fíjate en el detalle: Claude Code, el ejemplo que usa Bun para medirse, está compilado con Bun. Si vives en la terminal con herramientas así —el flujo que enseño en Construye con IA—, ese 20% lo notas en la fricción, no en el benchmark.

    Bun.serve() con HTTP/2 y HTTP/1.1 en el mismo puerto

    En el runtime, Bun 1.4.1 trae dos cambios que afectan a código real: HTTP/2 en Bun.serve() y Bun.write() con streaming a disco.

    El primero es un flag:

    Bun.serve({
      tls: { key, cert },
      http2: true,
      fetch(req) {
        return new Response("hi");
      },
    });
    

    Sin proxy delante, sin segundo servidor, sin decidir de antemano qué protocolo habla tu cliente.

    Si todavía estás en la pregunta anterior a esta —por qué Bun y no Node—, la respuesta larga la escribí en Bun reemplazando a Node.js en backend TypeScript. Aquí doy por hecho que ya la tienes resuelta.

    El segundo cambio: Bun.write() hace streaming a disco en vez de bufferizar en memoria.

    await Bun.write("./big.tar.gz", await fetch(url)); // => bytes escritos
    await Bun.write("./out.txt", readableStream); // antes escribía "[object ReadableStream]"
    

    En una descarga de 128 MiB, el pico de RSS baja de 161 MB a 13 MB. Ese primer ejemplo es el patrón que todos escribimos alguna vez para guardar un archivo remoto, y hasta hoy cargaba el archivo entero en RAM antes de tocar el disco.

    La segunda línea es directamente un bug arreglado: pasar un ReadableStream escribía el texto "[object ReadableStream]" en tu fichero. Si tienes archivos corruptos en producción con ese contenido exacto, ya sabes de dónde venían.

    También hay pause(), resume() y la propiedad isPaused en el WebSocket cliente, que es la pieza que faltaba para hacer backpressure decente:

    import { createWriteStream } from "node:fs";
    
    const file = createWriteStream("./feed.ndjson");
    const socket = new WebSocket("wss://example.com/feed");
    
    socket.addEventListener("message", (event) => {
      if (!file.write(event.data)) {
        socket.pause();
        file.once("drain", () => socket.resume());
      }
    });
    

    Sin eso, un feed rápido y un disco lento acababan siempre en el mismo sitio: memoria creciendo hasta que algo revienta.

    Memoria y detalles que se notan a las tres de la mañana

    La memoria en reposo baja. Un SSR de Next.js pasa de 222 MB a 142 MB una vez terminada la carga. En un contenedor con límite de 256 MB, eso es la diferencia entre dormir tranquilo y recibir alertas de OOM.

    Ojo con la lectura fácil de este tipo de cifras: lo mismo pasó con el 90% menos de memoria de Next.js 16.3, donde el número era real pero no era el de todo el mundo.

    En Buffer hay dos mejoras concretas, y quiero ser preciso porque esto se lee por ahí como "Buffer 9x más rápido": son 9,2x en writeFloatLE() y 7,2x en writeUInt8(). Métodos específicos, no la clase entera.

    AsyncLocalStorage va unas 2x más rápido y ya no asigna memoria en cada await. Si tienes tracing o contexto de request atravesando toda la aplicación, esto lo estabas pagando en cada salto asíncrono.

    Y llegan crypto.argon2() y crypto.argon2Sync(). Bun ya hasheaba con argon2id desde Bun.password; lo nuevo es tenerlo en la API de crypto, que es lo que te ahorra reescribir el módulo de auth cuando portas código de Node.

    Todos los números de Bun 1.4.1, en una tabla

    Caso medido Antes Bun 1.4.1 Mejora
    Bundle de zod 4.5 375,3 KB 77,3 KB 79% menos
    Bundle de effect 3.22 369,1 KB 163,6 KB 56% menos
    export * as con fp-ts 2.16 21,8 KB 3,2 KB 85% menos
    Archivos JS del dashboard de Medusa 349 245 30% menos
    Arranque de Claude Code 397 ms 318 ms 20% menos
    Instalación de Claude Code 376 MB 207 MB 45% menos
    Pico de RSS al descargar 128 MiB 161 MB 13 MB 92% menos
    Memoria en reposo de un SSR de Next.js 222 MB 142 MB 36% menos
    Buffer.writeFloatLE() 2,85 ns 0,31 ns 9,2x
    Buffer.writeUInt8() 2,24 ns 0,31 ns 7,2x

    Fuente: notas de la release de Bun v1.4.1.

    bun install --offline: es para tu CI, no para ti

    Dos flags nuevos en bun install.

    --offline falla si algo no está en caché. Cero red, sin excusas. --prefer-offline es la versión suave: usa la caché y se salta el refetch de metadatos, pero baja lo que falte.

    Los mismos valores viven en bunfig.toml:

    # bunfig.toml
    [install]
    offline = true       # equivale a --offline
    # prefer = "offline" # equivale a --prefer-offline
    

    --offline en local te va a molestar. En CI, con la caché restaurada antes del install, convierte un fallo de red del registry en un error reproducible en lugar de un build rojo aleatorio a las once de la noche.

    Y para monorepos, los workspaces admiten paquetes con node_modules autocontenidos:

    {
      "workspaces": {
        "packages": ["apps/*"],
        "selfContained": ["apps/desktop"]
      }
    }
    

    Útil cuando una app del monorepo se distribuye sola —un binario de escritorio, un contenedor— y necesita sus dependencias dentro, no hoisted arriba del todo.

    Si vienes de otro gestor, el movimiento de fondo es el mismo en todo el ecosistema: velocidad y builds reproducibles. Lo analicé cuando salió pnpm 12 reescrito en Rust.

    ¿Merece la pena actualizar a Bun 1.4.1? Qué migrar hoy y qué no

    Bun 1.4.1 no es una revolución. Es una release de consolidación, y el bloque del bundler es lo único que justifica que la instales esta semana.

    Tu situación Qué hacer con Bun 1.4.1 Por qué
    Compilas frontend o librerías con bun build Actualiza hoy La ganancia de tamaño es gratis y se mide en diez minutos
    Distribuyes ejecutables con bun build --compile Actualiza hoy 20% menos de arranque y 45% menos de instalación sin tocar código
    Bun solo ejecuta tu servidor Actualiza sin prisa HTTP/2 y menos RSS no arreglan nada que hoy funcione
    Estás en Node y te funciona No migres Una release no justifica cambiar de runtime en producción

    La primera fila es la que tiene premio inmediato: actualizas, relanzas el build y comparas el tamaño. Diez minutos. La tercera puede esperar al próximo sprint, porque HTTP/2 en el mismo puerto y menos RSS están muy bien, pero no arreglan nada que hoy funcione.

    Y no migres de Node a Bun solo por esta release. Si Node te sirve, esto no cambia la ecuación. Lo que cambia es que el argumento "el bundler de Bun todavía no está maduro" ya no se sostiene.

    Mi orden de trabajo esta semana es este: actualizar, lanzar el build, medir el bundle antes y después, y revisar dónde teníamos import() dinámicos puestos como optimización que en realidad no optimizaban nada.

    Ese ejercicio de medir antes y después es de lo que más discutimos en Dominicode Labs, porque casi siempre aparece algo que llevaba años ahí sin que nadie lo mirara.


    Preguntas frecuentes

    ¿Tengo que cambiar código para ganar el 79% menos de zod?

    No. La optimización ocurre al empaquetar, no al escribir: actualizas Bun, vuelves a lanzar bun build y el bundle sale más pequeño. Lo único que conviene revisar es si tus import() dinámicos destructuran lo que usan de verdad, porque cuanto más explícito seas al importar, más trabajo puede hacer el bundler.

    ¿El tree-shaking a través de import() dinámico funciona fuera de Bun?

    No. Es una optimización del bundler de Bun, no del lenguaje ni de TypeScript, así que el beneficio solo llega cuando el build lo hace bun build. Ejecutar tu aplicación con Bun pero empaquetar con otro bundler no te da estos números.

    ¿HTTP/2 en Bun.serve() necesita TLS?

    Sí en la práctica: el ejemplo oficial de la release configura tls junto a http2: true, que es el escenario normal para HTTP/2 en internet. Lo nuevo no es soportar el protocolo, es que HTTP/2 y HTTP/1.1 conviven en el mismo puerto, sin levantar dos servidores ni decidir por adelantado qué habla el cliente.

    ¿Cómo actualizo a Bun 1.4.1 y hay breaking changes?

    Con bun upgrade, y las notas de la release no anuncian ningún breaking change: es una versión de consolidación que cierra 202 issues sobre la 1.4.0. Si compilas con bun build, el orden sensato es actualizar, relanzar el build y comparar el tamaño antes y después.

    ¿bun install –offline sirve para CI?

    Sí, es su mejor uso: restauras la caché de Bun al principio del job y lanzas bun install --offline, de modo que si falta algo el build falla ahí y no a mitad del pipeline. Si prefieres algo menos estricto, --prefer-offline se salta el refetch de metadatos pero descarga lo que no esté en caché.

    ¿Merece la pena migrar de Node a Bun solo por esta release?

    No. Una release no justifica una migración de runtime en un proyecto que ya está en producción y funciona. Lo que sí merece la pena es probar bun build como bundler aunque ejecutes con Node: esa prueba cuesta una tarde y te da datos de tu proyecto en lugar de benchmarks ajenos.


    Si quieres ver este tipo de análisis en vídeo, con el proyecto delante y midiendo en directo, lo publico en el canal de YouTube de Dominicode.

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

  • 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.