Category: TypeScript

  • Streaming de respuestas de IA en tiempo real con NestJS y Vercel AI SDK

    Streaming de respuestas de IA en tiempo real con NestJS y Vercel AI SDK

    Hace un tiempo audité la arquitectura backend de una plataforma SaaS que ofrecía asistentes conversacionales para empresas. Su backend estaba construido en NestJS.

    Cada vez que un usuario enviaba una consulta compleja, la pantalla mostraba un indicador de carga girando durante 7 u 8 segundos desesperantes. De repente, ¡pum!, la pantalla escupía el bloque entero de 600 palabras.

    Los usuarios se quejaban de que la aplicación era "lenta e inestable".

    El problema no era la velocidad de la API de Anthropic o OpenAI. El problema era que el backend esperaba a que el LLM generara la respuesta completa antes de enviársela al cliente. Al migrar el controlador de NestJS a un modelo de streaming de respuestas de IA en tiempo real utilizando Vercel AI SDK, redujimos el Time to First Token (TTFT) percibido por el usuario a menos de 250 milisegundos.

    Por qué el Streaming es obligatorio en aplicaciones de IA

    En productos basados en modelos de lenguaje, la percepción de velocidad lo es todo. Como analizamos en nuestra guía sobre agentes de voz en tiempo real, la latencia percibida es el producto.

    Cuando una persona lee texto en pantalla a medida que se genera token a token:

    • Siente que la aplicación responde de forma instantánea.
    • Empieza a procesar la información de inmediato sin quedarse esperando a un spinner.
    • El servidor no tiene que acumular buffers de memoria gigantescos antes de responder.

    El desafío de NestJS: HTTP de respuesta continua vs. Controllers estándar

    Por defecto, los controladores de NestJS están diseñados para devolver objetos JSON o promesas que se resuelven antes de cerrar la conexión HTTP.

    Para emitir un stream continuo de datos desde un backend en NestJS hacia una aplicación frontend (React, Angular, Next.js, etc.), debemos aprovechar las capacidades de Server-Sent Events (SSE) o escribir directamente en la respuesta nativa Response del servidor.

    1. Instalación de Vercel AI SDK en NestJS

    npm install ai @ai-sdk/anthropic
    

    2. Creación del Servicio de IA (ai.service.ts)

    import { Injectable } from '@nestjs/common';
    import { anthropic } from '@ai-sdk/anthropic';
    import { streamText } from 'ai';
    
    @Injectable()
    export class AiService {
      async generarRespuestaStream(prompt: string) {
        const result = await streamText({
          model: anthropic('claude-3-5-sonnet-20241022'),
          system: 'Eres un asistente técnico especializado en arquitectura de software.',
          prompt,
        });
    
        // Retorna el stream directo de texto/tokens
        return result.toDataStreamResponse();
      }
    }
    

    3. El Controlador de NestJS con Streaming (ai.controller.ts)

    import { Controller, Post, Body, Res } from '@nestjs/common';
    import { Response } from 'express';
    import { AiService } from './ai.service';
    
    @Controller('api/chat')
    export class AiController {
      constructor(private readonly aiService: AiService) {}
    
      @Post('stream')
      async chatStream(@Body('prompt') prompt: string, @Res() res: Response) {
        const aiResponse = await this.aiService.generarRespuestaStream(prompt);
    
        // Copiamos los cabezales y pipeamos la respuesta directamente al cliente
        res.setHeader('Content-Type', aiResponse.headers.get('Content-Type') || 'text/plain; charset=utf-8');
        
        // Convertimos el ReadableStream a Node.js Stream para enviarlo
        const reader = aiResponse.body.getReader();
        
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          res.write(value);
        }
        
        res.end();
      }
    }
    

    Optimización de Tokens y Tipado Defensivo

    Al implementar llamadas en streaming, debes tener en cuenta dos aspectos críticos de producción:

    1. Gestión del Tokenizador en Español: Como detallamos en nuestro artículo sobre el coste de los tokens en español, el texto en español consume ligeramente más tokens por palabra que el inglés. El streaming continuo evita que la latencia acumulada por esta diferencia afecte al usuario.
    2. Manejo Defensivo de Errores: Si la API del LLM falla a mitad del stream, el servidor no puede devolver un código HTTP 500 porque los encabezados HTTP 200 ya han sido enviados. Aplicando programación defensiva en TypeScript, debes emitir un evento de error formateado dentro del propio stream para que el cliente lo maneje elegantemente.

    El streaming de respuestas convierte un backend estático en un motor dinámico en tiempo real que ofrece experiencias de usuario fluidas y profesionales.

    Si quieres aprender a construir arquitecturas de backend modernas integradas con IA, explora los Cursos de Dominicode. Y si buscas desarrollar proyectos de alto impacto junto a desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Es mejor usar Server-Sent Events (SSE) o WebSockets para streaming de texto?

    Para streaming unidireccional de texto desde el servidor hacia el cliente (como un chat de IA), Server-Sent Events (SSE) o HTTP Streaming es mucho más simple, eficiente y compatible con proxies que WebSockets, ya que reutiliza conexiones HTTP estándar.

    ¿Cómo consume este stream un cliente Frontend (React / Angular)?

    Librerías como Vercel AI SDK ofrecen hooks cliente como useChat() o useCompletion() en React/Next.js que consumen el endpoint en streaming automáticamente. En Angular o vanilla JS, puedes usar la API nativa fetch() examinando response.body.getReader().

    ¿Se pueden enviar metadatos estructurados (como IDs o fuentes) junto al stream de texto?

    Sí. Vercel AI SDK incluye soporte para StreamData, lo que te permite adjuntar JSONs con metadatos personalizados (ej. documentos de contexto RAG, fuentes o consumo de tokens) antes o durante la emisión del stream.

    ¿Cómo afecta el streaming al escalado de servidores en Kubernetes o Docker?

    Dado que las conexiones HTTP permanecen abiertas durante la generación del texto (habitualmente unos pocos segundos), debes asegurarte de ajustar el timeout de tus proxies o balancadores de carga (como NGINX o Traefik) para no cortar prematuramente la respuesta.


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

  • Patrones de diseño avanzados en TypeScript para aplicaciones en producción real

    Patrones de diseño avanzados en TypeScript para aplicaciones en producción real

    Hace unos meses revisé el repositorio de un proyecto TypeScript con más de dos años en producción. Al abrir el archivo de configuración del cliente principal, encontré comentarios como // TODO: quitar este any y casteos del tipo const data = response as unknown as UserData desperdigados por todo el código.

    El equipo se quejaba de que el compilador de TypeScript "les molestaba" en lugar de ayudarles.

    El problema era que estaban usando TypeScript simplemente como "JavaScript con anotaciones de tipo superficiales". No estaban aprovechando el sistema de tipos algebraicos ni los patrones de diseño expresivos que convierten a TypeScript en uno de los lenguajes más potentes para construir software resiliente.

    Conocer patrones de diseño avanzados en TypeScript no es para aprobar una entrevista técnica. Es lo que separa el código frágil del código mantenible que soporta años de evolución en producción.

    El espejismo del casting as Type

    El primer síntoma de una base de código TypeScript débil es el uso indiscriminado de aserciones de tipo (as).

    Cuando escribes as UserData, le estás diciendo al compilador: "Cállate, yo sé más que tú". Le estás quitando a TypeScript su superpoder principal: garantizar en tiempo de compilación que tus estructuras de datos son válidas.

    Como ya explicamos en nuestro análisis sobre programación defensiva en TypeScript, forzar tipos sin validación es la receta perfecta para lanzar excepciones TypeError: Cannot read properties of undefined en medio de la noche.

    3 Patrones de Diseño Esenciales para Developers Senior

    1. El Patrón Result (Discriminated Unions para manejo de errores)

    En lugar de lanzar excepciones con throw (que son invisibles en la firma de tus funciones), utiliza un tipo Result explícito basado en uniones discriminadas:

    type Success<T> = { readonly ok: true; readonly value: T };
    type Failure<E> = { readonly ok: false; readonly error: E };
    type Result<T, E = Error> = Success<T> | Failure<E>;
    
    function parseConfig(rawJson: string): Result<AppConfig, ParseError> {
      try {
        const data = JSON.parse(rawJson);
        if (!data.apiKey) {
          return { ok: false, error: new ParseError("apiKey es requerida") };
        }
        return { ok: true, value: data as AppConfig };
      } catch (e) {
        return { ok: false, error: new ParseError("JSON inválido") };
      }
    }
    
    // Uso obligatorio y seguro:
    const result = parseConfig(rawString);
    if (result.ok) {
      console.log(result.value.apiKey); // TypeScript sabe que ok es true y infiere el tipo de 'value'
    } else {
      console.error(result.error.message); // TypeScript infiere el tipo de 'error'
    }
    

    2. El Patrón Strategy para Proveedores de IA y Servicios

    Cuando construyes aplicaciones que interactúan con múltiples modelos de IA (OpenAI, Anthropic Claude, Ollama en local), el patrón Strategy te permite intercambiar algoritmos y proveedores sin modificar el código cliente:

    interface AIProviderStrategy {
      readonly name: string;
      generateCompletion(prompt: string): Promise<string>;
    }
    
    class AnthropicStrategy implements AIProviderStrategy {
      readonly name = "anthropic";
      async generateCompletion(prompt: string): Promise<string> {
        // Lógica específica para Anthropic API
        return "Respuesta de Claude";
      }
    }
    
    class OllamaLocalStrategy implements AIProviderStrategy {
      readonly name = "ollama";
      async generateCompletion(prompt: string): Promise<string> {
        // Lógica específica para modelo en local
        return "Respuesta de LLM local";
      }
    }
    
    class AIService {
      constructor(private strategy: AIProviderStrategy) {}
    
      setStrategy(newStrategy: AIProviderStrategy) {
        this.strategy = newStrategy;
      }
    
      async run(prompt: string) {
        return await this.strategy.generateCompletion(prompt);
      }
    }
    

    3. Builder Pattern con Validación en Tiempo de Compilación

    El patrón Builder permite crear objetos complejos garantizando que todos los parámetros requeridos se hayan establecido antes de instanciar la clase:

    type CompleteState = { host: string; port: number };
    
    class DatabaseConfigBuilder<State extends Partial<CompleteState> = {}> {
      private constructor(private readonly config: Partial<CompleteState>) {}
    
      static create(): DatabaseConfigBuilder<{}> {
        return new DatabaseConfigBuilder({});
      }
    
      setHost(host: string): DatabaseConfigBuilder<State & { host: string }> {
        return new DatabaseConfigBuilder({ ...this.config, host });
      }
    
      setPort(port: number): DatabaseConfigBuilder<State & { port: number }> {
        return new DatabaseConfigBuilder({ ...this.config, port });
      }
    
      build(this: DatabaseConfigBuilder<CompleteState>): DatabaseConfig {
        return new DatabaseConfig(this.config.host, this.config.port);
      }
    }
    

    Si intentas llamar a .build() sin haber establecido setHost() y setPort(), TypeScript rechazará la compilación de forma inmediata.


    Diseñar aplicaciones en TypeScript utilizando patrones expresivos y tipos algebraicos previene el 90% de los errores en producción antes de que el código toque el servidor.

    Al igual que discutimos al construir agentes de voz en tiempo real con TypeScript, el rigor en el tipado y en los contratos es lo que permite que tu código escale sin romperse. Y si combinas estos patrones con técnicas de graph engineering, tus aplicaciones serán limpias, modulares e inexpugnables.

    Si deseas dominar TypeScript avanzado y patrones de arquitectura modernos, explora los Cursos de Dominicode. Y si quieres construir software de producción en comunidad con otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Cuándo debo preferir type sobre interface en TypeScript?

    Como regla general: usa interface cuando estés definiendo contratos de objetos que pueden ser extendidos o implementados por clases (OOP). Usa type para uniones, tuplas, tipos primitivos mapeados y combinaciones algebraicas de tipos.

    ¿El uso de tipos avanzados degrada el rendimiento de la aplicación en producción?

    No. Todo el sistema de tipos de TypeScript se elimina por completo durante el proceso de transpilación a JavaScript. Tu bundle de producción solo contiene JavaScript puro, por lo que los tipos no agregan ni un solo byte de sobrecarga en tiempo de ejecución.

    ¿El patrón Result reemplaza por completo los bloques try/catch?

    Se recomienda usar el patrón Result para errores de dominio previsibles (fallos de validación, usuario no encontrado, saldo insuficiente). Los bloques try/catch se reservan para excepciones no controladas a nivel de infraestructura (caída de red, falta de memoria).

    ¿Por qué evitar el tipo any si a veces acelera el desarrollo?

    Usar any desactiva completamente el verificador de tipos de TypeScript para esa variable y para todas las expresiones derivadas de ella. Si necesitas un tipo genérico desconocido temporalmente, utiliza siempre unknown y realiza narrowing con guardas de tipo (type guards).


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

  • Por qué Bun está reemplazando a Node.js en backend con TypeScript: Rendimiento y DX sin bundlers

    Por qué Bun está reemplazando a Node.js en backend con TypeScript: Rendimiento y DX sin bundlers

    Hace un tiempo migramos la suite de microservicios e integraciones de un proyecto en backend desde Node.js hacia Bun.

    En el entorno anterior basado en Node.js, la cadena de herramientas (toolchain) incluía tsx para desarrollo local, esbuild para compilar TypeScript a JavaScript antes de desplegar, jest para pruebas unitarias y npm para gestionar paquetes. Instalar dependencias en CI tardaba 35 segundos. El arranque del servidor de desarrollo tomaba 4.2 segundos.

    Al migrar a Bun:

    • bun install redujo el tiempo de instalación a 650 milisegundos.
    • bun run dev arrancó el servidor de TypeScript de forma instantánea (0 ms).
    • bun test ejecutó 300 tests unitarios en 1.2 segundos (frente a los 14 segundos de Jest).

    Bun no es simplemente un ejecutor de JavaScript alternativo; es una navaja suiza que reemplaza de un plumazo a Node.js, npm, ts-node, esbuild, Vite y Jest en aplicaciones backend con TypeScript.

    La diferencia arquitectónica: V8 vs. JavaScriptCore y Zig

    Node.js y Deno están construidos sobre el motor V8 de Google (escrito en C++).

    Bun fue desarrollado desde cero por Jarred Sumner utilizando el lenguaje de programación Zig y el motor JavaScriptCore (JSC) desarrollado por Apple para Safari.

    Esta decisión de diseño otorga a Bun ventajas competitivas clave:

    1. Menor tiempo de arranque (Cold Starts): JavaScriptCore arranca y genera bytecode significativamente más rápido que V8, lo que convierte a Bun en el runtime idóneo para funciones Serverless e integraciones con IA.
    2. Uso de memoria optimizado: El recolector de basura de JSC gestiona los objetos de heap con menor consumo de RAM en estado inactivo.
    3. Manejo de archivos a nivel de kernel: Operaciones I/O de lectura de archivos (Bun.file()) y llamadas de red HTTP son procesadas mediante llamadas de sistema de bajo nivel en Zig.
    ┌─────────────────────────────────────────────────────────┐
    │ Ecosistema Node.js Tradicional                          │
    │ ┌───────────────┬───────────────┬─────────────────────┐ │
    │ │ Node.js (V8)  │ npm / pnpm    │ ts-node / esbuild   │ │
    │ └───────────────┴───────────────┴─────────────────────┘ │
    └──────────────────────────┬──────────────────────────────┘
                               │ REEMPLAZADO POR:
    ┌──────────────────────────▼──────────────────────────────┐
    │ Runtime Bun (Zig + JavaScriptCore)                      │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ Ejecución nativa de TypeScript / JSX sin transpilar  │ │
    │ ├─────────────────────────────────────────────────────┤ │
    │ │ Bundler + Test Runner + Package Manager (bun.lockb) │ │
    │ ├─────────────────────────────────────────────────────┤ │
    │ │ Driver SQLite/Postgres nativo + WebSockets + Servidor│ │
    │ └─────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────┘
    

    3 Características que Cambian la Experiencia de Desarrollo (DX)

    1. Ejecución nativa de TypeScript sin transpiladores externos

    En Node.js, para ejecutar un archivo .ts, necesitas configurar un transpilador como ts-node, tsx o compilar primero a una carpeta dist/ con tsc.

    En Bun, ejecutas directamente:

    bun run src/index.ts
    

    Bun transquila al vuelo archivos .ts, .tsx, .js y .jsx en memoria C++ sin requerir archivos tsconfig complejos ni configuraciones de Build.

    2. Gestor de paquetes ultrarrápido (bun install)

    bun install utiliza llamadas de sistema de copiado de memoria en Linux/macOS (copy-on-write) y un formato de lockfile binario (bun.lockb).

    Instalar un paquete como zod o hono toma milisegundos porque Bun aprovecha un sistema de caché global compartido entre todos tus proyectos locales.

    3. API HTTP y WebSockets nativas de alto rendimiento

    Servir peticiones HTTP en Bun requiere un bloque de código mínimo sin depender de Express o Fastify:

    import { serve } from "bun";
    
    serve({
      port: 3000,
      fetch(req) {
        const url = new URL(req.url);
        if (url.pathname === "/api/health") {
          return Response.json({ status: "ok", runtime: "Bun 1.2" });
        }
        return new Response("Not Found", { status: 404 });
      },
      websocket: {
        message(ws, message) {
          ws.send(`Eco: ${message}`);
        },
      },
    });
    

    Al combinar este rendimiento con los principios de programación defensiva en TypeScript, construyes servidores web robustos que responden en microsegundos y resisten picos de tráfico masivos.

    Comparativa con Next.js y Turbopack

    Como analizamos en nuestro informe sobre la optimización de memoria en Next.js y Turbopack, el consumo de recursos en herramientas de desarrollo ha sido un dolor constante para los desarrolladores.

    Bun soluciona este problema desde la raíz: consume hasta un 60% menos de memoria RAM durante la compilación y ejecución que Node.js con Webpack o Vite.

    Además, al simplificar la arquitectura de dependencias siguiendo buenas prácticas de graph engineering, tus repositorios se vuelven más fáciles de mantener tanto para desarrolladores como para agentes de IA.


    Bun no es el futuro del desarrollo backend en TypeScript; es el presente en proyectos de alta velocidad.

    Si quieres dominar el desarrollo backend moderno, microservicios y mejores prácticas de arquitectura con TypeScript y Bun, explora los Cursos de Dominicode. Y si quieres colaborar en proyectos reales junto a desarrolladores senior, súmate a Dominicode Labs.

    Preguntas frecuentes

    ¿Bun es compatible con el ecosistema existente de npm y módulos de Node.js?

    Sí. Bun implementa soporte para las APIs globales de Node.js (fs, path, http, stream, buffer) y soporta la importación de módulos tanto CommonJS (require) como ESM (import). La inmensa mayoría de paquetes de npm (incluyendo Prisma, Drizzle, Hono, NestJS y Express) funcionan de forma transparente en Bun.

    ¿Se puede utilizar Bun para producción en servidores Linux?

    Absolutamente. Bun cuenta con soporte de producción completo para entornos Linux x64 y ARM64. Grandes plataformas de despliegue como Vercel, Fly.io, Railway y AWS Lambda ofrecen ejecución nativa de aplicaciones basadas en Bun.

    ¿Cómo funciona bun test en comparación con Jest o Vitest?

    bun test es un test runner compatible con la sintaxis de Jest (describe, it, expect, beforeEach). Al estar integrado directamente en el runtime en lenguaje Zig, ejecuta suites de tests hasta 10 veces más rápido que Jest y 3 veces más rápido que Vitest sin requerir plugins adicionales.

    ¿Debo migrar todos mis proyectos existentes de Node.js a Bun de inmediato?

    Para proyectos nuevos, microservicios, scripts de automatización e integraciones con IA, Bun es la recomendación número uno. Para proyectos legacy de gran tamaño en Node.js, se sugiere comenzar migrando primero la ejecución de bun install y bun test en tus pipelines de CI/CD para ganar velocidad de inmediato antes de cambiar el runtime de producción.


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

  • Agentes de voz en tiempo real: la latencia es el producto

    Agentes de voz en tiempo real: la latencia es el producto

    Un cliente me pidió una demo de un asistente telefónico para reservas. La monté en un fin de semana: transcripción con Whisper, un LLM para razonar, un TTS decente para responder. En mis pruebas funcionaba. Entendía todo, respondía bien, la voz sonaba natural.

    Se la enseñé por teléfono a alguien de su equipo. Preguntó por una mesa para el sábado. Silencio. Y entonces hizo lo que hace cualquiera cuando no le contestan: dijo "¿hola?".

    Ese "¿hola?" me enseñó lo único que de verdad importa al construir agentes de voz en tiempo real: el agente no había fallado. Había tardado 1,2 segundos en empezar a hablar. Y 1,2 segundos, en una conversación, no se perciben como lentitud. Se perciben como que la llamada se ha cortado.

    La latencia no es una métrica que optimizas al final. Es el producto.

    Qué es un agente de voz en tiempo real

    Un agente de voz en tiempo real es un sistema que escucha al usuario, decide qué responder y contesta hablando, todo dentro de la misma conversación y sin pasar por turnos escritos. Se diferencia de un chatbot en que el canal es audio continuo, y de un asistente de voz clásico en que quien decide es un LLM con acceso a herramientas, no un árbol de intenciones.

    Se construye de dos maneras: encadenando reconocimiento de voz (STT), modelo de lenguaje (LLM) y síntesis de voz (TTS), o con un modelo speech-to-speech nativo que recibe audio y emite audio. La diferencia entre las dos no está en lo bonita que suena la voz. Está en la latencia, y en cuánta información sobrevive por el camino.

    El listón lo puso la evolución, no OpenAI

    Hay un dato que explica por qué 1,2 segundos rompen la ilusión. Un estudio publicado en PNAS por Stivers y su equipo midió los huecos entre turnos en conversaciones reales de diez lenguas, de comunidades indígenas tradicionales a lenguas mayoritarias.

    El resultado fue incómodamente uniforme. La moda del hueco entre que uno termina de preguntar y el otro empieza a responder cae entre 0 y +200 ms en todas las lenguas estudiadas, con una moda global de 0 ms y una mediana entre lenguas de +100 ms. Las diferencias entre unas lenguas y otras caben en un margen de 250 ms respecto a la media global.

    Piensa en lo que implica. Nadie escucha una pregunta, la entiende, formula la respuesta y la articula en 200 milisegundos. No da tiempo. Lo que hacemos los humanos es predecir: empezamos a construir la respuesta mucho antes de que el otro termine.

    Tu agente no predice. Espera. Y ese hueco es exactamente donde la gente cuelga.

    Este es el presupuesto de latencia con el que trabajo cuando monto uno de estos. No son cifras de ningún benchmark: es la repartición que me funciona para no pasarme del umbral.

    Tramo Objetivo Qué lo dispara
    Detección de fin de turno (VAD) 200-500 ms silence_duration_ms alto, eagerness: "low"
    Primer token del modelo 200-400 ms contexto largo sin caché, modelo grande
    Primer audio de salida 100-300 ms TTS sin streaming
    Red y jitter 50-200 ms WebSocket en móvil, sin buffer adaptativo
    Total percibido por debajo de 800 ms por encima de 1 s el usuario dice "¿hola?"

    Por qué el pipeline encadenado suena mal en un agente de voz

    El diseño por defecto del developer que viene de chatbots de texto es el pipeline encadenado: STT → LLM → TTS. Es el que se entiende y el que puedes montar con tres proveedores que ya conoces.

    Tiene dos problemas de fondo que no se arreglan cambiando de proveedor.

    El primero es que las latencias suman. Cada etapa tiene su propio tiempo hasta el primer byte, y ninguna empieza hasta que la anterior le da algo con lo que trabajar. Puedes tener un STT rápido, un LLM rápido y un TTS rápido, y aun así un conjunto lento, porque mides cada pieza aislada mientras el usuario mide la cadena entera.

    Se mitiga con streaming agresivo — transcripciones parciales al modelo, tokens al TTS según salen — pero eso convierte tu pipeline en un problema de concurrencia, no en tres llamadas HTTP.

    El segundo es peor, porque no se arregla con ingeniería. El texto intermedio es un cuello de botella de información.

    Cuando alguien dice "no…" con duda, alargando la vocal, y cuando dice "No." tajante, tu STT te entrega la misma palabra. Has tirado a la basura el tono, la vacilación, el énfasis, la prisa, el enfado. Toda la información que un humano usa para decidir cómo responder desaparece antes de que el modelo la vea. Después le pides al TTS que reconstruya emoción a partir de texto plano, y suena a lo que es: una reconstrucción.

    Esto no mata el pipeline encadenado. Lo coloca en su sitio: es la arquitectura correcta para transcribir audio por lotes y extraer datos — una nota de voz, una reunión grabada, un mensaje asíncrono. Ahí Whisper sigue siendo excelente, y lo conté en detalle en captura y procesamiento de audio en Angular usando Whisper.

    Lo que no puedes es coger la arquitectura de transcripción por lotes y esperar que sostenga una conversación.

    Qué cambia con speech-to-speech en un agente de voz

    El modelo speech-to-speech elimina el viaje de ida y vuelta por el texto. Entra audio, sale audio, y el mismo modelo que entiende es el que habla.

    La consecuencia técnica es que la prosodia sobrevive. El modelo no lee una transcripción de lo que dijiste: procesa el audio, con sus pausas y su entonación, igual que procesa cualquier otra modalidad. Si te resulta raro que un modelo "escuche", la idea general está en qué es un modelo multimodal: el audio también acaba siendo tokens.

    La consecuencia práctica es que desaparecen dos saltos de red y dos colas de espera.

    A cambio pierdes flexibilidad. No puedes cambiar la voz sin cambiar de modelo, no tienes un punto intermedio en texto donde auditar lo que el agente está a punto de decir, y estás casado con un proveedor. Es un trade-off real, y hay negocios regulados donde ese checkpoint de texto no es negociable.

    Pero si tu producto es una conversación, la conversación gana.

    Los cuatro problemas de un agente de voz en producción

    Aquí es donde la demo del fin de semana se separa del producto.

    Barge-in: el agente tiene que callarse

    Cuando el usuario empieza a hablar mientras el agente habla, el agente debe cortar. Inmediatamente. Un agente que termina su frase mientras tú le hablas encima resulta insoportable en tres segundos.

    El detalle sucio es que cancelar la respuesta no basta. Al cortar, el servidor cree que ha dicho todo el audio que generó, pero el usuario solo ha oído lo que le dio tiempo a reproducirse. Si no corriges esa divergencia, el historial de la conversación contiene frases que el usuario nunca escuchó, y el modelo seguirá razonando como si las hubiera dicho.

    Por eso existe conversation.item.truncate: le dices al servidor en qué milisegundo exacto se quedó el audio realmente reproducido. Y ese milisegundo tiene que venir de tu reproductor, no de los bytes que has recibido del socket. Recibir no es reproducir. Este es el bug número uno de todo el que monta esto por primera vez.

    Detección de turno: saber cuándo ha terminado de hablar

    El VAD por volumen — silencio durante X milisegundos, luego respondo — funciona bien hasta que alguien dice "quiero reservar para… espera que mire… el sábado". Ese "espera que mire" incluye una pausa, y tu agente entra a saco a media frase.

    La Realtime API de OpenAI ofrece dos modos: server_vad, que trocea por silencio con parámetros de threshold, prefix_padding_ms y silence_duration_ms, y semantic_vad, que usa un modelo para estimar la probabilidad de que hayas terminado y ajusta el timeout de forma dinámica, controlado con eagerness (low, auto/medium, high).

    La regla práctica: eagerness: "low" cuando el usuario tiene que pensar o recordar datos, high cuando son respuestas cortas de sí/no. Esto se nota más en el resultado final que cambiar de modelo.

    Transporte: WebSocket no es la respuesta por defecto

    La documentación oficial es clara. WebRTC para clientes de navegador y móvil que capturan o reproducen audio directamente. WebSocket cuando tu servidor ya recibe audio crudo de un pipeline de medios o de una centralita. SIP para telefonía.

    La razón de fondo es que WebRTC trae de fábrica lo que en WebSocket tendrías que construir tú: control de jitter, adaptación a pérdida de paquetes, cancelación de eco. En una wifi doméstica no notarás la diferencia. En 4G en movimiento, sí — y es justo el escenario donde vive un agente de voz de verdad.

    Desde el navegador, el flujo recomendado pasa por tu backend: el cliente genera la oferta SDP, tu servidor la reenvía a https://api.openai.com/v1/realtime/calls con la configuración de sesión y devuelve la respuesta. Nunca expongas la API key en el cliente; para eso están los secretos efímeros de /v1/realtime/client_secrets.

    Coste: el audio se paga como audio

    Muchos proyectos se caen justo aquí, después de la demo. Precios oficiales consultados el 31 de julio de 2026, por millón de tokens:

    Modelo Audio entrada Audio entrada cacheada Audio salida
    gpt-realtime-2.1 $32,00 $0,40 $64,00
    gpt-realtime-2.1-mini $10,00 $0,30 $20,00
    gemini-3.1-flash-live-preview $3,00 (~$0,005/min) $12,00 (~$0,018/min)

    Lo interesante no es la cifra absoluta, es la comparación dentro del mismo modelo. gpt-realtime-2.1 cobra $4,00 por millón de tokens de texto de entrada y $32,00 por millón de tokens de audio de entrada. Ocho veces más por el mismo millón de tokens, solo por la modalidad.

    El otro número que deberías tener tatuado: el audio de entrada cacheado cuesta $0,40 frente a $32,00. Ochenta veces menos. En una conversación larga, donde cada turno reenvía todo el contexto anterior, el caché deja de ser una optimización y pasa a ser la diferencia entre un producto viable y uno que no. Si nunca has mirado de cerca cómo se cuentan los tokens y por qué el idioma influye en la factura, escribí sobre ello en tokens en español y el coste del tokenizador.

    El modelo mini cuesta exactamente 3,2 veces menos en audio no cacheado, tanto de entrada como de salida. Para el 80% de los agentes de voz reales — reservas, soporte de primer nivel, cualificación de leads — es más que suficiente.

    El código: una sesión realtime de verdad

    Esto es Node/Bun con TypeScript, a nivel de protocolo. Lo pongo así a propósito: los SDKs te esconden justo las partes que necesitas entender.

    import WebSocket from "ws";
    import { z } from "zod";
    
    // Tus dos piezas: el reproductor de audio del cliente y tu API de negocio.
    declare const player: {
      enqueue(chunk: Buffer): void;
      stop(): void;
      playedMs(): number; // ms realmente reproducidos al usuario
    };
    declare const reservas: { buscar(q: unknown): Promise<unknown> };
    
    const MODEL = "gpt-realtime-2.1";
    
    const ws = new WebSocket(`wss://api.openai.com/v1/realtime?model=${MODEL}`, {
      headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    });
    
    const send = (event: Record<string, unknown>) => ws.send(JSON.stringify(event));
    
    ws.on("open", () => {
      send({
        type: "session.update",
        session: {
          type: "realtime",
          instructions:
            "Eres el asistente de reservas de Bar Nostrum. Frases cortas. " +
            "Nunca leas listas largas en voz alta: ofrece dos opciones como mucho.",
          audio: {
            input: {
              // OJO: format es un objeto, no la cadena "pcm16" de la beta antigua.
              format: { type: "audio/pcm", rate: 24000 },
              turn_detection: {
                type: "semantic_vad",
                eagerness: "low",         // el usuario tiene que recordar fechas
                interrupt_response: true, // permite barge-in
              },
            },
            output: { voice: "cedar" },
          },
          tools: [
            {
              type: "function",
              name: "buscar_disponibilidad",
              description: "Consulta mesas libres para una fecha y nº de comensales.",
              parameters: {
                type: "object",
                properties: {
                  fecha: { type: "string", description: "Formato YYYY-MM-DD" },
                  comensales: { type: "integer" },
                },
                required: ["fecha", "comensales"],
              },
            },
          ],
        },
      });
    });
    

    Ahora el bucle de eventos. Fíjate en player.playedMs(): ese es el punto donde casi todo el mundo se equivoca.

    let currentItemId: string | null = null;
    
    ws.on("message", async (raw) => {
      const event = JSON.parse(raw.toString());
    
      switch (event.type) {
        // El usuario habla mientras el agente habla → barge-in
        case "input_audio_buffer.speech_started": {
          if (!currentItemId) break;
    
          // Defensivo: con interrupt_response:true el servidor ya cancela solo.
          // Lo dejo por si algún día bajas a server_vad sin interrupción.
          send({ type: "response.cancel" });
          send({
            type: "conversation.item.truncate",
            item_id: currentItemId,
            content_index: 0,
            // CLAVE: lo que el usuario ha OÍDO, no lo que has recibido del socket.
            audio_end_ms: player.playedMs(),
          });
          player.stop();
          currentItemId = null;
          break;
        }
    
        case "response.output_audio.delta": {
          currentItemId = event.item_id;
          player.enqueue(Buffer.from(event.delta, "base64"));
          break;
        }
    
        case "response.function_call_arguments.done": {
          await handleToolCall(event.call_id, event.arguments);
          break;
        }
    
        case "response.done": {
          currentItemId = null;
          break;
        }
      }
    });
    

    Y la tool call, que es donde esto deja de ser una demo:

    const BuscarDisponibilidad = z.object({
      fecha: z.iso.date(),
      comensales: z.number().int().min(1).max(20),
    });
    
    async function handleToolCall(callId: string, rawArgs: string) {
      // 1. Valida SIEMPRE. El modelo alucina argumentos igual que alucina texto.
      const parsed = BuscarDisponibilidad.safeParse(JSON.parse(rawArgs));
      if (!parsed.success) {
        return sendToolOutput(callId, { error: "argumentos_invalidos" });
      }
    
      // 2. Si la tool tarda, habla antes de que el silencio se note.
      const filler = setTimeout(() => {
        send({
          type: "response.create",
          response: {
            // CLAVE: out-of-band. Sin esto, cuando la tool resuelva lanzarás un
            // segundo response.create mientras el relleno sigue generando audio.
            conversation: "none",
            instructions:
              "Di una frase muy corta indicando que estás consultando. " +
              "No inventes el resultado.",
          },
        });
      }, 400);
    
      const mesas = await reservas.buscar(parsed.data);
      clearTimeout(filler);
    
      sendToolOutput(callId, mesas);
    }
    
    function sendToolOutput(callId: string, output: unknown) {
      send({
        type: "conversation.item.create",
        item: {
          type: "function_call_output",
          call_id: callId,
          output: JSON.stringify(output),
        },
      });
      send({ type: "response.create" });
    }
    

    Ese setTimeout de 400 ms resuelve el silencio incómodo. Mientras la tool consulta tu API, el agente dice "déjame que lo mire" y luego encadena con el resultado. Es lo que hace un humano al teléfono, y sin ello cualquier consulta que tarde más de medio segundo se siente como una caída.

    La validación con Zod no es opcional. Los argumentos de una tool call son texto generado por un modelo, y tratarlos como datos de confianza es la misma clase de error que confiar en el body de una petición HTTP sin validarlo. Si quieres afinar esa capa, la trabajo a fondo en el curso de Zod para validación en TypeScript.

    Si prefieres abstracción, el SDK de agentes de OpenAI para JavaScript expone RealtimeAgent, RealtimeSession y un helper backgroundResult para tools largas. El diseño de herramientas es el mismo que expliqué en el servidor de herramientas con el Agent SDK de Anthropic.

    Cuándo NO usar un agente de voz

    La voz se está metiendo en sitios donde estorba.

    No uses voz cuando el usuario tenga que dar datos exactos y largos: un IBAN, un email, una referencia alfanumérica. Deletrear "bezael arroba dominicode punto com" por teléfono es peor experiencia que un input de texto, siempre.

    No uses voz cuando el resultado sea una lista para comparar. La voz es un canal estrictamente serial: no puedes escanear, ni volver atrás, ni ver tres precios a la vez.

    No uses voz cuando el error sea caro e irreversible. Confirmar una transferencia bancaria con un VAD que puede cortarte a media frase es pedir un incidente.

    La voz gana en tres escenarios: cuando las manos están ocupadas, cuando el input es abierto y ambiguo — describir un problema es más rápido hablando que rellenando diez campos — y cuando el canal ya es voz porque el usuario ha llamado por teléfono.

    Si tu caso no encaja en ninguno, un formulario gana. Y un formulario cuesta cero dólares por millón de tokens.

    Qué hacer hoy

    Ese número —los milisegundos hasta que el agente empieza a hablar— es tu producto. El prompt, la voz y las funcionalidades solo importan si está por debajo del umbral en el que la gente deja de sentir que habla con una máquina rota. Así lo mido:

    1. Monta una sesión realtime mínima con una sola tool enganchada.
    2. Instrumenta dos marcas de tiempo: input_audio_buffer.speech_stopped y el primer response.output_audio.delta.
    3. Mide 20 turnos con tu red real y tu dispositivo real. En localhost todo va rápido.
    4. Si la mediana pasa de 800 ms, ataca en este orden: caché de audio de entrada, gpt-realtime-2.1-mini, WebRTC en lugar de WebSocket, y frase de relleno para las tools lentas.
    5. Vuelve a medir. Repite hasta que nadie diga "¿hola?".

    Si quieres construir esto con método en lugar de a golpe de prueba y error, es el enfoque que enseño en Construye con IA: de la idea al producto con Claude Code. Y si prefieres no pelearte con esto en solitario, en Dominicode Labs es donde desmontamos este tipo de arquitecturas entre developers que están construyendo cosas parecidas.

    Preguntas frecuentes

    ¿Merece la pena todavía el pipeline STT → LLM → TTS?

    Sí, pero para otro problema. Es la arquitectura correcta cuando necesitas un checkpoint de texto auditable, cuando quieres cambiar de proveedor de voz sin tocar el resto, o cuando el trabajo es transcripción y análisis por lotes en lugar de conversación. Para una conversación en tiempo real, un modelo speech-to-speech nativo te ahorra saltos de red y conserva la información prosódica que el texto intermedio destruye.

    ¿WebSocket o WebRTC para un agente de voz en tiempo real?

    La documentación de OpenAI recomienda WebRTC para clientes de navegador y móvil que capturan o reproducen audio directamente, WebSocket cuando tu servidor ya recibe audio crudo de un pipeline de medios, y SIP para telefonía. La diferencia se nota sobre todo en redes móviles: WebRTC trae control de jitter, adaptación a pérdida de paquetes y cancelación de eco de serie.

    ¿Cuánto cuesta un agente de voz con IA?

    A 31 de julio de 2026, gpt-realtime-2.1 cuesta $32 por millón de tokens de audio de entrada y $64 de salida; el modelo mini, $10 y $20. Gemini publica precios por minuto para gemini-3.1-flash-live-preview: unos $0,005 por minuto de audio de entrada y $0,018 de salida. La clave del coste real no es el precio de lista sino el caché: en el modelo grande, el audio de entrada cacheado baja de $32 a $0,40 por millón.

    ¿Cómo evito que el agente corte al usuario cuando hace una pausa para pensar?

    Usa detección de turno semántica en lugar de VAD por volumen. semantic_vad estima con un modelo la probabilidad de que el usuario haya terminado, en lugar de contar milisegundos de silencio, y ajusta el timeout dinámicamente. Con eagerness: "low" das más margen, que es lo que quieres cuando el usuario tiene que recordar una fecha o un dato.

    ¿Puedo usar Claude para un agente de voz en tiempo real?

    No como modelo speech-to-speech nativo: los modelos de Anthropic aceptan texto e imagen como entrada y devuelven texto, no audio. Ahora bien, como cerebro de un pipeline encadenado con STT y TTS externos funcionan muy bien — Fable 5 para el trabajo más exigente, Opus 5 y Sonnet 5 para el grueso, y Haiku 4.5 cuando la latencia manda por encima de todo. Es la opción sólida si necesitas ese checkpoint de texto intermedio o si ya tienes tus herramientas montadas sobre el Agent SDK de Anthropic. Para latencia conversacional pura, hoy la vía nativa pasa por los modelos realtime de OpenAI o Gemini Live.

    ¿Cuánta latencia es aceptable en un agente de voz?

    Por debajo de 800 ms desde que el usuario deja de hablar hasta que el agente emite el primer audio. A partir de un segundo la gente deja de percibirlo como lentitud y empieza a percibirlo como una llamada cortada. El estudio de Stivers y su equipo en PNAS muestra que en las diez lenguas analizadas los hablantes minimizan el silencio entre turnos, con una moda global de 0 ms y una mediana de +100 ms.

    ¿Se puede conectar un agente de voz a una centralita o a un número de teléfono?

    Sí. Para telefonía la vía es SIP: la Realtime API acepta conexiones SIP además de WebRTC y WebSocket, y proveedores como Twilio o Telnyx enrutan el número hacia tu sesión. Si tu pipeline de medios ya te entrega audio crudo en el servidor, WebSocket también sirve. WebRTC está pensado para clientes de navegador y móvil.


    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.

  • Tests unitarios lentos: el número de Vitest que casi nadie mira

    Tests unitarios lentos: el número de Vitest que casi nadie mira

    Nuestro job de tests en CI tardaba doce minutos clavados. Setecientos siete ficheros, cuatro mil cuatrocientos tests. Nadie lo cuestionaba: una suite grande tarda, y punto.

    Hoy lo hemos dejado en seis minutos y quince segundos. Sin borrar un solo test, sin runners más caros, sin paralelizar nada. Solo cambiando qué entorno arranca cada fichero.

    Y lo interesante no es el 48 % que nos ahorramos. Es que llevábamos meses con tests unitarios lentos mirando el número equivocado.

    El número equivocado es el total. El total te dice que tienes un problema, pero no te dice dónde se va el tiempo. Y sin el dónde, optimizar es tirar cosas a la pared: cambias el entorno, subes los threads, añades runners, y a veces sale bien y a veces sale peor y nunca sabes por qué.

    Vitest te da el dónde al final de cada ejecución. Lo tienes impreso en tu terminal ahora mismo.


    Resumen rápido

    • El total del Duration te dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí.
    • environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contra tests, nunca contra Duration.
    • En nuestra suite: 803,6 s de environment contra 156 s de tests. Cinco veces más en montar el escenario que en ejecutarlo.
    • La causa: environment: 'jsdom' global para 707 ficheros, de los que solo 208 tocan el DOM.
    • El fix: dos proyectos de Vitest, la extensión decide el entorno. .test.ts a node, .test.tsx a DOM. 12m → 6m 15s en CI.
    • Shardear no arregla esto: algo más de la mitad del tiempo es coste fijo de arranque, y cada shard lo vuelve a pagar entero.

    Tests unitarios lentos: el desglose que Vitest imprime y nadie lee

    Debajo del Duration hay un paréntesis con seis campos: transform, setup, collect, tests, environment y prepare.

    En nuestro caso, los dos que importan salían así:

    Duration  106.31s (transform …, setup …, collect …, tests 156.00s, environment 803.60s, prepare …)
    

    Recorto los campos que no vienen al caso. Fíjate en la contradicción aparente: el run entero duró 106 segundos, pero dice que gastó 803 en environment.

    No es un bug. environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. La suite corre en dieciséis workers y cada uno monta su propio entorno por fichero. La cifra que ves es la suma de todos ellos, así que puede ser más de siete veces mayor que el reloj de pared.

    La regla de lectura del desglose de Vitest es comparar acumulado contra acumulado: environment contra tests, nunca contra Duration. Duration es wall-clock; los otros dos son tiempos sumados entre ficheros y workers.

    Y ahí el número deja de ser abstracto: 803,6 segundos montando el escenario contra 156 ejecutando los tests. Cinco veces más en preparar que en actuar.

    Cuando environment multiplica varias veces a tests, el problema no son los tests lentos: es el arranque del entorno.


    707 ficheros arrancando un navegador, 208 usándolo

    El origen estaba en una línea del config: environment: 'jsdom', global, para los 707 ficheros.

    Conté los que tocaban el DOM de verdad. Eran 208. Los 499 restantes —parsers, cálculo de precios, mapeo de rutas de API, validadores— montaban un navegador falso entero para no usarlo jamás.

    Ese es el gasto que estábamos pagando cinco veces sobre el trabajo real. No era un problema de rendimiento del emulador. Era que la mitad larga de la suite no necesitaba emulador ninguno.

    La solución fue partir la suite en dos proyectos de Vitest con una regla que cabe en una frase: la extensión decide el entorno.

    El config, con Vitest 4.1.10 (julio de 2026). La clave test.projects sustituyó a workspace en Vitest 3.2, así que en versiones anteriores esto no aplica:

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    
    export default defineConfig({
      test: {
        projects: [
          {
            // Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.test.ts'],
            },
          },
          {
            // Componentes: DOM emulado + testing-library.
            plugins: [react()],
            test: {
              name: 'dom',
              environment: 'jsdom',
              include: ['src/**/*.test.tsx'],
              setupFiles: ['./src/test/setup-dom.ts'],
            },
          },
        ],
      },
    })
    

    Elegir la extensión como criterio no es cosmético. Con globs por carpeta o por sufijo (*.spec.ts y *.component.spec.ts) te toca mantener un exclude, porque el primer patrón se traga los ficheros del segundo y esos tests se ejecutan dos veces, una de ellas en el entorno equivocado y fallando por un motivo que parece un bug de tu código.

    .test.ts y .test.tsx no se solapan nunca. Cero exclude, cero ambigüedad, y una regla que un compañero nuevo entiende sin preguntar: si tu test importa JSX, es .tsx y tiene DOM.

    Si trabajas en Angular la idea es idéntica desde que Vitest es el runner por defecto. Cambian los globs, no el razonamiento.

    Después dimos el segundo paso: cambiar una palabra en el proyecto dom, de jsdom a happy-dom. En un A/B aislado sobre esos 208 ficheros, 48,9 s → 34,9 s. Unos catorce segundos. Útil, pero un orden de magnitud por debajo de lo que dio separar los entornos.

    La comparativa completa entre los dos emuladores, con benchmark y las APIs que le faltan a cada uno, la tengo aparte en el post sobre happy-dom o jsdom.

    El resultado de las dos cosas juntas:

    Ámbito Antes Después Δ
    Job Test en CI 12m 00s 6m 15s −48 %
    Suite local (16 cores) 106,3 s 46,0 s −57 %
    environment (acumulado entre ficheros) 803,6 s 137 s −83 %
    Ficheros / tests 707 / 4.400 707 / 4.400 sin cambios

    Mismos tests. Mismas aserciones. Misma cobertura.


    El efecto secundario: dos tests que llevaban meses mintiendo

    Al cambiar el entorno, dos tests empezaron a fallar.

    Y tenían razón.

    El patrón era este, y lo he visto en todos los proyectos en los que he entrado:

    vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(ok(productos))
      .mockResolvedValueOnce(ok(stock))
    

    Parece un mock. No lo es.

    vi.spyOn(global, 'fetch') sin implementación envuelve la función original y sigue llamándola. Lo único que intercepta son las respuestas que has encolado con mockResolvedValueOnce. Y esa cola se agota: la primera llamada recibe productos, la segunda stock, y la tercera sale a la red de verdad.

    Nuestro componente hacía tres llamadas.

    Llevaba meses pidiendo datos a localhost:3000 desde el runner de CI. jsdom se lo tragaba en silencio y el test seguía en verde. Con happy-dom la petición real quedó a la vista, y ahí aparecieron los 401.

    El arreglo son cuatro líneas, y es la clase de cosa que debería estar en el setup de cualquier suite:

    // setup-dom.ts
    import { beforeEach, vi } from 'vitest'
    
    beforeEach(() => {
      vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear'))
    })
    

    Un default que revienta. Si un test necesita una respuesta, la encola encima; si se le olvida una llamada, el test falla con un mensaje que dice exactamente qué pasó, en vez de irse a internet a buscar suerte.

    Añade también restoreMocks: true en el config: mockRejectedValue en un beforeEach no vacía la cola de ...Once que haya dejado el test anterior, y esa cola sobrante es una fuga entre tests igual de silenciosa que la que acabas de tapar.

    Yo esto ya no lo discuto: un test que llega a la red no es un test unitario, es una apuesta. Es lento, es flaky, y depende del firewall del runner. Diseñar los mocks para que el hueco falle ruidosamente en vez de degradar en silencio es la mitad del trabajo de testear bien, y es la parte que más tiempo dedico a explicar en el curso de Testing en Angular.

    Nadie planea encontrar estos bugs. Aparecen cuando tocas los cimientos.


    ¿Merece la pena shardear los tests unitarios? Los números dijeron que no

    Nos dio un 27 % a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que en unitarios la mayor parte del tiempo es arranque compartido, y repartirlo no lo divide: lo multiplica. Así llegamos ahí.

    Semanas antes habíamos partido la suite de E2E en shards concurrentes y el wall-clock se había desplomado. Fue de esas victorias que te dejan con ganas de repetir.

    Así que la pregunta era obvia: si funcionó con E2E, ¿por qué no con los unitarios?

    Los números decían que sí. De los 375 segundos del job, solo 31 eran setup —checkout, pnpm install, build de las librerías internas—, un 8 %. Con un overhead fijo tan bajo, repartir en tres debería habernos dejado en torno a los dos minutos y medio. Una mejora del orden del 60 %.

    Abrimos el PR, lo lanzamos, y esto es lo que salió:

    Job Tiempo
    Test (1) 4m 19s
    Test (2) 4m 32s ← wall-clock
    Test (3) 3m 57s
    Test (otros paquetes) 1m 38s

    375 s → 272 s. Un 27 %, a cambio de cuatro runners en vez de uno.

    Volvimos al desglose, que es lo que había que haber hecho antes de escribir el PR:

    Ámbito Ficheros Tiempo de tests
    Job completo 707 333 s
    Un shard 236 226 s

    Léelo despacio. Un tercio de los ficheros tarda el 68 % de lo que tardan todos.

    Si ajustas una recta T(n) = F + n·v con esos dos puntos, sale un coste fijo F de unos 172 segundos y una pendiente de 0,23 segundos por fichero. Traducido: de los 333 segundos, algo más de la mitad es peaje que pagas antes de ejecutar un solo test. Solo unos 160 dependen de cuántos ficheros tengas.

    Y aquí toca ser honesto con el método: dos puntos y dos incógnitas significa que la recta pasa por ambos por construcción. Es aritmética, no un perfilado. Te da el orden de magnitud del reparto entre lo fijo y lo variable, que es justo lo que necesitas para decidir, pero no lo cites como si fuera una medida.

    Ese coste fijo es transform más importación del grafo de dependencias, en frío, sin caché de Vite en el runner. Y cada shard lo paga entero, otra vez, desde cero.

    Con 3 shards pagas ese peaje tres veces. Con 6, seis. No hace falta el modelo para verlo: el shard más rápido de los tres, con 236 ficheros en vez de 707, todavía tardó 3m 57s. Por muchos runners que enchufes, el suelo se queda en unos cuatro minutos.

    La lección de E2E no transfería, y visto desde aquí es evidente. En Playwright cada test es trabajo independiente de navegador: repartir divide de verdad. En unitarios, la mayor parte del tiempo es arranque compartido, y repartir trabajo compartido no lo divide, lo multiplica.

    El sharding no era la palanca. La palanca era eliminar los ~172 segundos de coste fijo que cada shard vuelve a pagar entero.


    El PR sigue abierto, y creo que así está bien

    El PR #36 no está mergeado ni cerrado. Un 27 % por 4x runners es un trade flojo, y las tres opciones siguen sobre la mesa:

    • Cerrarlo. Los cien segundos no compensan cuadruplicar el consumo de CI ni la complejidad de un job matricial.
    • Bajarlo a 2 shards. Menos ganancia, la mitad de coste, y sospecho que el punto donde la curva todavía compensa.
    • Aparcarlo y atacar el coste fijo. Cachear node_modules/.vite entre runs, si esa caché existe en tu setup, porque hoy se reconstruye en frío cada vez. Si funciona, mejora los tres escenarios a la vez, incluido el de un solo runner.

    La tercera es la que tiene mejor pinta, y precisamente por eso no quiero decidirla con la misma prisa con la que abrimos el PR. Primero medir el arranque en caliente, después decidir.


    Qué hacer hoy si tienes tests unitarios lentos

    1. Lanza tus tests y mira el paréntesis del final. Solo eso.
    2. Compara environment con tests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Si environment es el doble de tests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo.
    3. Cuenta cuántos ficheros importan JSX o tocan document de verdad. En nuestro caso eran 208 de 707. En el tuyo probablemente sea una proporción parecida, porque un catálogo, un carrito o un dashboard tienen mucha más lógica que pintura.
    4. Separa por extensión. .test.ts a node, .test.tsx a DOM. Veinte minutos de trabajo, cero riesgo, ningún test tocado.

    La tesis de todo esto no es "usa happy-dom" ni "shardea tus tests". Es que medir el coste correcto —no el tiempo total, sino en qué se va— convierte una optimización a ciegas en un cambio de config de veinte líneas. El mismo desglose que nos quitó seis minutos nos evitó después tirar cuatro runners a un problema que no era de paralelismo.

    Y que de vez en cuando, al levantar los cimientos, encuentras un test que llevaba meses saliendo a internet sin que nadie se enterara.

    Si quieres ver este tipo de decisiones tomadas sobre proyectos reales, con los runs y los números delante en vez de con opiniones, es lo que hacemos en Dominicode Labs.


    Preguntas frecuentes sobre tests unitarios lentos

    ¿Por qué mis tests unitarios son lentos si cada test tarda milisegundos?

    Casi siempre porque el tiempo no se va en ejecutar los tests, sino en preparar el entorno de cada fichero. En nuestra suite, el desglose de Vitest daba 803,6 segundos acumulados en environment frente a 156 en tests: cinco veces más en montar el escenario que en actuar. Mientras esa proporción esté desequilibrada, optimizar aserciones o subir el número de threads no te va a dar nada: estarías acelerando la parte pequeña.

    ¿Qué significa environment en el resumen de Vitest?

    Es el tiempo dedicado a instanciar el entorno de test (jsdom, happy-dom o node) para cada fichero, sumado entre todos los workers. Es tiempo acumulado entre ficheros y workers, no wall-clock, y por eso puede ser mucho mayor que el Duration total: nosotros teníamos 803,6 segundos de environment en un run de 106,3 segundos corriendo sobre dieciséis workers. La comparación que tiene sentido es environment contra tests, porque ambas cifras están acumuladas de la misma forma.

    ¿Cómo separo los tests que necesitan DOM de los que no en Vitest?

    Con test.projects en vitest.config.ts: un proyecto con environment: 'node' para la lógica pura y otro con DOM emulado, plugin del framework y setupFiles para los tests de componente. Lo que mejor nos ha funcionado es decidir por extensión, .test.ts contra .test.tsx, porque son globs que no se solapan y no necesitas exclude. Si separas por carpeta o por sufijo compuesto, un mismo fichero puede caer en los dos proyectos y ejecutarse dos veces, una de ellas en el entorno equivocado.

    ¿Merece la pena shardear los tests unitarios en CI?

    Depende de qué proporción de tu tiempo sea coste fijo de arranque, y hay que medirlo antes de abrir el PR. En nuestro caso, tres shards dieron un 27 % de mejora a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que cada shard vuelve a pagar entero el transform y la importación del grafo de dependencias, así que ese coste no se reparte, se multiplica. Con E2E la historia es distinta porque cada test es trabajo independiente de navegador y repartir sí divide.

    ¿Por qué vi.spyOn(global, 'fetch') no mockea mis llamadas?

    Porque spyOn sin implementación envuelve la función original y sigue llamándola. Solo intercepta las respuestas que hayas encolado con mockResolvedValueOnce, y esa cola se agota: en cuanto tu código hace una llamada más de las que encolaste, esa petición sale a la red de verdad. El arreglo es poner siempre un default que falle, con vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear')) en el setup, y encolar las respuestas concretas encima en cada test.

    ¿Esto aplica igual en Angular?

    Sí, y desde Angular 21 y 22 aún más, porque Vitest pasó a ser el runner por defecto y el entorno DOM dejó de ser una decisión implícita del builder de Karma. Cambian los globs, que serán .spec.ts con algún criterio propio para distinguir tests de componente de tests de servicio, pero el diagnóstico es idéntico: mira el desglose de environment, cuenta cuántos specs necesitan document y manda el resto a node.


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

  • Cómo construir un agente de IA y su MCP server paso a paso

    Cómo construir un agente de IA y su MCP server paso a paso

    Hace tres semanas un dev me escribió por Telegram con un MCP server funcionando. Lo había construido siguiendo un tutorial. Arrancaba, registraba sus tools, Claude Code lo detectaba. Todo perfecto.

    Su pregunta era: "¿y ahora cómo hago que mi agente lo use?".

    No supo responderse porque el tutorial terminaba justo ahí. Y ese es el problema con casi todo el material que hay sobre MCP: te enseñan a construir el enchufe, pero nunca el aparato que se enchufa. O al revés — te enseñan a montar un agente con tools locales y jamás mencionan por qué querrías sacarlas a un servidor.

    Son dos mitades de la misma pieza. Y separadas no sirven de mucho.

    Este post construye las dos. Un MCP server real en TypeScript, un agente que lo consume, y el puente entre ambos. Código verificado contra el SDK, no de memoria. Al final sabrás también cuándo no deberías montar un MCP server, que es una decisión que mucha gente se salta.


    Las dos mitades: quién expone y quién consume

    Un MCP server expone capacidades: tools, resources y prompts. No tiene inteligencia. No decide nada. Es un catálogo de funciones con un contrato estándar delante. Si no tienes claro el concepto de fondo, este post explica qué es Model Context Protocol antes de meterte en código.

    Un agente es lo contrario: tiene el modelo, tiene el bucle, y decide qué llamar y cuándo. Lo que no tiene es acceso a tu mundo — a tu base de datos, a tu API interna, a tus postmortems.

    MCP es el estándar que une las dos cosas sin que se conozcan entre sí. Escribes el server una vez, y lo consumen Claude Code, Claude Desktop, tu agente propio y el agente que escriba tu compañero el mes que viene.

    Esa reutilización es todo el valor de MCP. Recuérdalo, porque en la última sección lo usaremos para decidir si te hace falta.

    Vamos a construir un server sobre un caso que a cualquiera con sistemas en producción le suena: un histórico de incidencias. Datos internos, API que nadie más va a integrar, y consultas que un modelo puede hacer mucho mejor que un dashboard.


    Paso 1: el MCP server en TypeScript

    Construir un MCP server en TypeScript requiere tres piezas: el paquete @modelcontextprotocol/sdk con zod como peer dependency, una instancia de McpServer, y un transporte stdio. Este paso monta las tres sobre un caso real.

    Primero, versiones. Y aquí hay que ser preciso porque el ecosistema está en transición.

    La versión de producción hoy es la v1, en el paquete @modelcontextprotocol/sdk (última: 1.29.0). Es sobre la que vas a construir. Hay una v2 en beta que lo cambia bastante, y le dedico una sección entera más abajo — pero no construyas sobre ella todavía.

    mkdir mcp-incidencias && cd mcp-incidencias
    npm init -y
    npm install @modelcontextprotocol/sdk zod
    npm install -D typescript @types/node tsx
    

    zod es peer dependency obligatoria del SDK v1, no es opcional.

    En tu package.json añade "type": "module", y en el tsconfig.json usa "module": "NodeNext" y "target": "ES2022". Sin eso los imports con extensión .js te van a dar guerra.

    Ahora el servidor. Archivo src/server.ts:

    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
    import { z } from 'zod';
    
    type Severidad = 'baja' | 'media' | 'alta';
    
    interface Incidencia {
      id: string;
      servicio: string;
      fecha: string;
      severidad: Severidad;
      titulo: string;
      causaRaiz: string;
    }
    
    // En producción esto sale de tu base de datos.
    const INCIDENCIAS: Incidencia[] = [
      {
        id: 'INC-101',
        servicio: 'checkout-api',
        fecha: '2026-05-14',
        severidad: 'alta',
        titulo: 'Timeouts masivos en pasarela de pago',
        causaRaiz: 'Pool de conexiones agotado tras un deploy sin migrar el límite.'
      },
      {
        id: 'INC-118',
        servicio: 'checkout-api',
        fecha: '2026-06-02',
        severidad: 'media',
        titulo: 'Latencia elevada en cálculo de impuestos',
        causaRaiz: 'Consulta N+1 introducida al añadir el desglose por región.'
      },
      {
        id: 'INC-124',
        servicio: 'auth-service',
        fecha: '2026-06-21',
        severidad: 'alta',
        titulo: 'Sesiones invalidadas de forma masiva',
        causaRaiz: 'Rotación de claves JWT desplegada sin periodo de solapamiento.'
      }
    ];
    
    const server = new McpServer({ name: 'incidencias', version: '1.0.0' });
    

    Fíjate en los imports: llevan la extensión .js y la ruta interna del paquete (/server/mcp.js). No es @modelcontextprotocol/sdk a secas. Es el error más habitual al empezar.

    Ahora registramos la primera tool:

    const ORDEN: Record<Severidad, number> = { baja: 0, media: 1, alta: 2 };
    
    server.registerTool(
      'buscar_incidencias',
      {
        title: 'Buscar incidencias',
        description:
          'Busca incidencias de producción de un servicio, filtrando por severidad mínima. ' +
          'Úsala cuando necesites el histórico de fallos de un servicio concreto.',
        inputSchema: {
          servicio: z.string().describe('Nombre del servicio, por ejemplo: checkout-api'),
          severidadMinima: z.enum(['baja', 'media', 'alta']).default('baja')
        },
        outputSchema: {
          total: z.number(),
          incidencias: z.array(
            z.object({
              id: z.string(),
              fecha: z.string(),
              severidad: z.string(),
              titulo: z.string()
            })
          )
        }
      },
      async ({ servicio, severidadMinima }) => {
        const encontradas = INCIDENCIAS.filter(
          (i) => i.servicio === servicio && ORDEN[i.severidad] >= ORDEN[severidadMinima]
        ).map(({ id, fecha, severidad, titulo }) => ({ id, fecha, severidad, titulo }));
    
        const output = { total: encontradas.length, incidencias: encontradas };
    
        return {
          content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
          structuredContent: output
        };
      }
    );
    

    Un detalle que ahorra tardes: inputSchema acepta tanto la forma en crudo ({ servicio: z.string() }) como un z.object() completo. El SDK normaliza las dos por dentro y el JSON Schema que acaba llegando al modelo es idéntico. Uso la forma cruda porque es menos ruido, pero si vienes de otra librería y te sale envolver, no rompes nada.

    Guárdalo, porque dentro de un momento vamos a ver otra librería donde esa flexibilidad no existe.

    Lo segundo: la description no es documentación, es prompt. Es literalmente lo único que el modelo lee para decidir si usa esta tool. Una descripción vaga es una tool que nunca se llama, o que se llama cuando no toca. Escríbela pensando en el modelo, incluyendo cuándo usarla.

    Segunda tool, con manejo de errores:

    server.registerTool(
      'detalle_incidencia',
      {
        title: 'Detalle de incidencia',
        description: 'Devuelve la causa raíz completa de una incidencia por su ID (formato INC-XXX).',
        inputSchema: {
          id: z.string().describe('Identificador, por ejemplo: INC-101')
        }
      },
      async ({ id }) => {
        const incidencia = INCIDENCIAS.find((i) => i.id === id);
    
        if (!incidencia) {
          return {
            content: [{ type: 'text', text: `No existe ninguna incidencia con ID ${id}.` }],
            isError: true
          };
        }
    
        return {
          content: [
            {
              type: 'text',
              text: `${incidencia.id} — ${incidencia.titulo}\nServicio: ${incidencia.servicio}\nFecha: ${incidencia.fecha}\nSeveridad: ${incidencia.severidad}\nCausa raíz: ${incidencia.causaRaiz}`
            }
          ]
        };
      }
    );
    

    isError: true en lugar de lanzar una excepción. La diferencia importa: con isError el modelo recibe el mensaje y puede corregirse solo — reintentar con otro ID, o decirle al usuario que no existe. Si lanzas, revientas la conexión y el agente se queda ciego.

    Y el arranque:

    const transport = new StdioServerTransport();
    await server.connect(transport);
    
    console.error('MCP server de incidencias escuchando en stdio');
    

    console.error, nunca console.log. En transporte stdio, stdout es el canal JSON-RPC. Un solo console.log mete texto suelto en la tubería y rompe el protocolo con un error de parseo que no dice nada útil. Todo tu logging va a stderr.

    Ya tienes la mitad de la pieza. Si quieres comprobar que funciona antes de seguir, no hace falta registrarlo en ningún cliente: npx @modelcontextprotocol/inspector npx tsx src/server.ts levanta MCP Inspector, la herramienta oficial, y te deja ver las tools registradas e invocarlas a mano desde el navegador.

    Si además quieres usarlo desde Claude Code, tengo aparte las notas sobre registrar un MCP server en Claude Code — es el complemento natural de esta sección, no un camino alternativo.


    Paso 2: el agente que consume las tools

    El agente se construye con el Tool Runner del SDK de Anthropic (client.beta.messages.toolRunner), que ejecuta por ti el bucle completo: llama al modelo, ejecuta la tool que pida, le devuelve el resultado, y repite hasta obtener una respuesta final.

    Aquí hay una confusión que veo constantemente y conviene despejarla antes de escribir una línea, porque son dos productos distintos de Anthropic:

    • Tool Runner (client.beta.messages.toolRunner, dentro del SDK normal @anthropic-ai/sdk): automatiza el bucle sobre las tools que defines. Sin tools integradas, sin sandbox. Tú pones todo.
    • Claude Agent SDK (@anthropic-ai/claude-agent-sdk): es Claude Code empaquetado como librería, con tools integradas de serie — leer y escribir ficheros, bash, grep.

    No son versiones distintas de lo mismo. Para este caso queremos el Tool Runner, porque lo que nos interesa es controlar exactamente qué tools existen.

    npm install @anthropic-ai/sdk
    

    Un agente mínimo con una tool local:

    import Anthropic from '@anthropic-ai/sdk';
    import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
    import { z } from 'zod';
    
    const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
    
    const buscarIncidencias = betaZodTool({
      name: 'buscar_incidencias',
      description: 'Busca incidencias de producción de un servicio por severidad mínima.',
      inputSchema: z.object({
        servicio: z.string().describe('Nombre del servicio, por ejemplo: checkout-api'),
        severidadMinima: z.enum(['baja', 'media', 'alta']).default('baja')
      }),
      run: async (input) => {
        // aquí llamarías a tu API real
        return JSON.stringify({ servicio: input.servicio, total: 0, incidencias: [] });
      }
    });
    
    const respuesta = await client.beta.messages.toolRunner({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      thinking: { type: 'adaptive' },
      messages: [
        {
          role: 'user',
          content: '¿Qué incidencias graves ha tenido checkout-api? Resume el patrón que veas.'
        }
      ],
      tools: [buscarIncidencias]
    });
    
    console.log(respuesta.content);
    

    Fíjate en la diferencia con el server: en betaZodTool el inputSchema tiene que ser un z.object() completo. Aquí no hay normalización que te salve — pasarle la forma en crudo no funciona.

    Las tres convenciones de schema que se confunden entre sí

    El mismo concepto cambia de formato según la librería. Es la causa más frecuente de tools que se registran pero nunca se llaman bien:

    Librería y función Propiedad Formato esperado
    MCP SDK v1 — registerTool inputSchema Forma cruda o z.object(); el SDK normaliza las dos
    Anthropic SDK — betaZodTool inputSchema z.object() completo, obligatorio
    Anthropic SDK — betaTool inputSchema JSON Schema plano (el que devuelve listTools())

    Las tres reciben inputSchema en camelCase. El input_schema en snake_case que quizá tengas visto es lo que viaja por la red hacia la API, no lo que le pasas al helper. Escribir snake_case en cualquiera de los tres revienta con un TypeError.

    Tres cosas más sobre los parámetros, que están cambiadas respecto a lo que quizá tengas memorizado:

    • El modelo es claude-opus-4-8. Sin sufijo de fecha.
    • thinking va con { type: 'adaptive' }. budget_tokens está eliminado en Opus 4.8 y devuelve un 400.
    • temperature, top_p y top_k rechazan cualquier valor que no sea el por defecto y devuelven 400. Si arrastras un temperature: 0 de un proyecto viejo, esa llamada falla. Se dirige el comportamiento por prompt, no por sampling.

    max_tokens alrededor de 16000 para peticiones normales, hasta ~64000 si haces streaming.

    El Tool Runner ejecuta el bucle completo por ti: llama al modelo, si pide una tool la ejecuta, le devuelve el resultado, y repite hasta que el modelo da una respuesta final. Ese bucle es el corazón de cualquier agente — y si quieres entender por qué la calidad del bucle importa más que el modelo que metas dentro, lo desarrollo aquí. Para los fundamentos de la API, este crash course te cubre.


    Paso 3: conectar las dos mitades

    Conectar el agente con el MCP server consiste en levantar un cliente MCP, pedirle sus tools con listTools() y traducirlas al formato del Tool Runner con betaTool. Así el agente ejecuta las tools reales del server en vez de copias locales.

    Ahora lo que casi nadie enseña. El agente del paso anterior tiene la tool duplicada en local. Queremos que consuma las tools reales del MCP server, sin reescribirlas.

    Para eso montamos un cliente MCP, le preguntamos qué tools tiene, y las traducimos al formato del Tool Runner:

    import Anthropic from '@anthropic-ai/sdk';
    import { betaTool } from '@anthropic-ai/sdk/helpers/beta/json-schema';
    import { Client } from '@modelcontextprotocol/sdk/client/index.js';
    import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
    
    const transport = new StdioClientTransport({
      command: 'npx',
      args: ['tsx', 'src/server.ts']
    });
    
    const mcp = new Client({ name: 'agente-incidencias', version: '1.0.0' });
    await mcp.connect(transport);
    
    // 1. Descubrimos las tools que expone el server
    const { tools } = await mcp.listTools();
    
    // 2. Las convertimos en tools ejecutables para el Tool Runner
    const puente = tools.map((tool) =>
      betaTool({
        name: tool.name,
        description: tool.description ?? '',
        inputSchema: tool.inputSchema as any,
        run: async (input) => {
          const resultado = await mcp.callTool({
            name: tool.name,
            arguments: input as Record<string, unknown>
          });
          return JSON.stringify(resultado.content);
        }
      })
    );
    
    // 3. El agente ya usa las tools reales del server
    const respuesta = await new Anthropic().beta.messages.toolRunner({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      thinking: { type: 'adaptive' },
      messages: [
        {
          role: 'user',
          content:
            'Revisa las incidencias graves de checkout-api, mira el detalle de cada una ' +
            'y dime si hay un patrón común en las causas raíz.'
        }
      ],
      tools: puente
    });
    
    console.log(respuesta.content);
    await mcp.close();
    

    Eso es el circuito completo. Aquí usamos betaTool en lugar de betaZodTool porque listTools() devuelve JSON Schema, no Zod. Encaja directo: le pasas el inputSchema tal cual llega del server.

    Y ojo con el detalle que más despista de todo el post: los helpers reciben inputSchema en camelCase, pero lo que viaja a la API es input_schema en snake_case. Si escribes tools a mano contra la API cruda usas snake_case; con los helpers, siempre camelCase. Escribir input_schema: dentro de betaTool no da un error de validación bonito — revienta con un TypeError antes de tocar la red.

    Cuando entiendas el mecanismo, el SDK ya trae ese puente hecho:

    import { mcpTools } from '@anthropic-ai/sdk/helpers/beta/mcp';
    
    const puente = mcpTools(tools, mcp);
    

    Merece la pena haber escrito el map a mano una vez: cuando el helper falle, sabrás qué está haciendo por dentro.

    Lo potente es que ese map no sabe nada de tus tools. Añade una tercera tool al server y el agente la tiene disponible en el siguiente arranque, sin tocar el código del agente. Ahí es donde MCP paga lo que cuesta.

    Al ejecutarlo verás al agente encadenar solo: llama a buscar_incidencias, recibe dos IDs, llama a detalle_incidencia con cada uno, y razona sobre las causas raíz. Nadie le dijo el orden.

    La otra vía: el conector MCP remoto

    Si en vez de stdio despliegas el server sobre HTTP, la API de Claude puede conectarse a él directamente, sin cliente MCP en tu código:

    const message = await client.beta.messages.create({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      betas: ['mcp-client-2025-11-20'],
      mcp_servers: [
        {
          type: 'url',
          url: 'https://incidencias.tudominio.com/mcp',
          name: 'incidencias'
        }
      ],
      tools: [{ type: 'mcp_toolset', mcp_server_name: 'incidencias' }],
      messages: [{ role: 'user', content: '¿Qué incidencias graves tuvo checkout-api?' }]
    });
    

    El conector MCP remoto exige declarar mcp_servers y tools a la vez. Declarar solo mcp_servers hace que la petición se rechace con un error de validación, aunque parezca redundante — ya has dicho dónde está el server, ¿para qué repetirlo? Cada server declarado en mcp_servers necesita su entrada { type: 'mcp_toolset', mcp_server_name: '<nombre>' } dentro de tools, y el mcp_server_name debe coincidir exactamente con el name del server.

    Ojo también: esta vía requiere un server con URL pública. Un server stdio no sirve aquí, para eso está el puente de arriba. La documentación del conector MCP de la API de Anthropic detalla el resto de campos disponibles.


    MCP v2 y la spec 2026-07-28: qué cambia y por qué no tienes que migrar

    El 28 de julio de 2026 salen la spec MCP 2026-07-28 y las SDK estables de la v2. Vas a ver posts en tono de urgencia. Ignóralos.

    La documentación oficial es explícita en dos puntos. Sobre las versiones, la v1.x sigue siendo "the supported release for production" y mantiene bugfixes y parches de seguridad al menos 6 meses después de que v2 sea estable. Y sobre el protocolo, la guía de la revisión lo cierra: "Nothing in v2 puts a 2026-07-28 byte on the wire by default" — hablar la revisión nueva es siempre un opt-in explícito. Migrar a v2 es opcional y va separado de la fecha del protocolo.

    Puedes seguir las dos fuentes de primera mano: la especificación del protocolo y el SDK de TypeScript en GitHub.

    El servidor que acabas de construir sigue funcionando. No hay nada que correr a arreglar.

    Dicho eso, la v2 no es un cambio cosmético y merece que sepas qué trae, porque cambia decisiones de arquitectura:

    Se parte el paquete. El monolítico @modelcontextprotocol/sdk desaparece en favor de @modelcontextprotocol/server, @modelcontextprotocol/client y @modelcontextprotocol/core. La v2 no sale como versión nueva del paquete viejo: son paquetes distintos. Por eso no hay riesgo de que te llegue sola en un npm update.

    Protocolo stateless. Cuando activas la revisión nueva, desaparecen el handshake initialize y la gestión de sesión. Traducido a infraestructura: escalas con un round-robin normal, sin sticky sessions. Si has sufrido balanceo con sesiones MCP, esta es la razón para migrar. Ojo: en la v2 el modo de 2025 sigue siendo el por defecto; la revisión 2026-07-28 se activa explícitamente.

    Multi Round-Trip Requests. Una tool puede pedir input al usuario a mitad de llamada, sin mantener un stream abierto — con inputRequired() y acceptedContent(). Confirmaciones y flujos de autorización dejan de ser un apaño.

    Cabeceras enrutables Mcp-Method y Mcp-Name, para que gateways y rate limiters enruten sin parsear el body. Si expones MCP detrás de un API gateway, esto te ahorra trabajo.

    Bring-your-own-schema. inputSchema y outputSchema aceptan cualquier Standard Schema: Zod v4 y ArkType directos, Valibot vía adaptador, o JSON Schema plano con fromJsonSchema. Se acabó estar atado a Zod. El registro pasa a server.registerTool(name, config, handler).

    Si vas a experimentar con la beta, el consejo oficial es fijar versiones exactas y poner cotas superiores en las dependencias, para no comerte un major por sorpresa.

    Mi recomendación: construye en v1, lee la guía de v2, y migra cuando tengas un motivo concreto — escalado horizontal o flujos que necesiten input a media llamada. No antes.


    Cuándo NO necesitas un MCP server

    Esta sección te puede ahorrar una semana.

    Si tu agente va a usar solo tus propias tools, dentro de tu propio proceso, no montes un MCP server. El Tool Runner con tools locales (betaZodTool y punto) es más simple, más rápido de depurar y no añade un proceso extra ni serialización por medio. Todo el paso 1 de este post sobra en ese escenario.

    MCP gana cuando aparece la palabra reutilización:

    • Quieres las mismas tools en Claude Code, en Claude Desktop y en tu agente.
    • Varios equipos van a consumir la misma capacidad y no quieres que cada uno la reimplemente.
    • Quieres una frontera de permisos clara: el server decide qué se puede hacer, el agente solo pide.
    • Necesitas versionar y desplegar las capacidades por separado del agente.

    Si no marcas ninguna, tu MCP server es una capa de indirección que no compra nada.

    Y una consecuencia que se ve poco: en cuanto varios clientes consumen tus tools, las descripciones dejan de ser tuyas y pasan a ser una API pública. Cambiar una description puede romper el comportamiento del agente de otro equipo sin que nada falle en rojo. Trátalas con el mismo cuidado que un contrato.


    Qué hacer con esto hoy

    Coge el código del paso 1, cámbiale el array INCIDENCIAS por una consulta real a tu base de datos, y ejecuta el puente del paso 3. En una tarde tienes un agente hablando con tus datos internos.

    Y cuando lo tengas funcionando, la parte difícil no será el código. Será decidir qué tools expones y cómo las describes — porque ahí es donde un agente pasa de demo a herramienta que usas todos los días.

    Ese salto, el de convertir una prueba de concepto en producto, es justo lo que trabajamos en el curso Construye con IA: De la Idea al Producto con Claude Code, con este mismo flujo de specs, tools y agentes. Si prefieres el método antes que la herramienta, en el libro Spec-Driven Development está el sistema completo para definir qué construyes antes de escribir la primera línea.

    Y si quieres los proyectos completos y las versiones de esto que corren en producción, están en Dominicode Labs.


    FAQ

    ¿Puedo usar el mismo MCP server con Claude Code y con mi agente propio a la vez?

    Sí, y es exactamente para lo que sirve MCP. El server no sabe quién le llama. Claude Code lo lanza como proceso hijo por stdio, y tu agente hace lo mismo con StdioClientTransport. Mismo binario, dos consumidores, cero código duplicado.

    ¿Tengo que migrar mi server a la v2 el 28 de julio?

    No. La v2 llega en paquetes nuevos (@modelcontextprotocol/server, /client y /core), no como actualización del paquete actual, así que no te va a llegar por un npm update. La v1.x sigue siendo la versión soportada para producción y mantiene bugfixes y parches de seguridad al menos 6 meses tras la salida de v2. Además, hablar la revisión 2026-07-28 es siempre un opt-in explícito: nada la pone en el cable por defecto.

    ¿Cuál es la diferencia real entre el Tool Runner y el Claude Agent SDK?

    El Tool Runner automatiza el bucle sobre tools que defines tú, y nada más: sin tools integradas, sin sandbox. El Claude Agent SDK es Claude Code como librería, y viene con tools de ficheros, bash y grep de serie. Si quieres control total sobre qué puede hacer el agente, Tool Runner. Si quieres un agente que opere sobre un repositorio desde el minuto uno, Agent SDK.

    Mi server arranca pero el cliente da error de parseo JSON. ¿Qué pasa?

    Casi seguro tienes un console.log en algún sitio. En stdio, stdout es el canal JSON-RPC exclusivo del protocolo. Cualquier texto que escribas ahí corrompe el flujo. Cambia todos los console.log por console.error y vuelve a probar.

    ¿Por qué mi tool aparece registrada pero el modelo nunca la llama?

    Dos causas, por frecuencia. La primera es la description: si es vaga, el modelo no sabe cuándo aplica. Escríbela diciendo explícitamente en qué situación usarla. La segunda es que el schema no describa bien los campos — añade .describe() a cada uno, porque el modelo los lee para saber con qué rellenarlos.

    ¿Cómo pruebo mi MCP server sin registrarlo en un cliente?

    Con MCP Inspector, la herramienta oficial: npx @modelcontextprotocol/inspector npx tsx src/server.ts. Abre una interfaz web donde ves las tools registradas, su schema, y puedes invocarlas con argumentos a mano. Es la forma más rápida de saber si el fallo está en el server o en cómo lo consume el cliente.

    ¿Puedo usar temperature para que el agente sea más determinista?

    No con Opus 4.8. temperature, top_p y top_k rechazan cualquier valor que no sea el por defecto y devuelven un 400. Tampoco existe ya budget_tokens para thinking — se usa thinking: { type: 'adaptive' }. Si migras código de modelos anteriores, revisa esos parámetros primero. El comportamiento se dirige por prompt.

    ¿El conector MCP de la API sirve para un server local por stdio?

    No. mcp_servers con type: 'url' necesita un endpoint HTTP accesible desde la API de Anthropic. Para un server local usas el puente del paso 3: cliente MCP por stdio y las tools traducidas al Tool Runner.


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

    Más contenido sobre agentes e IA aplicada al desarrollo en el canal de YouTube de Dominicode. Y si estás empezando con agentes, esta guía es el punto de partida.

  • Servidor de herramientas para tu agente de IA (sin MCP)

    Servidor de herramientas para tu agente de IA (sin MCP)

    Un cliente me escribió en enero. Llevaba tres semanas intentando montar un servidor de herramientas para que un agente consultara el stock de su ecommerce.

    Tenía la API montada desde 2021. Endpoints limpios, autenticación, tests. Todo funcionando en producción con miles de peticiones al día. Pero estaba convencido de que para "conectarle IA" necesitaba reescribirlo todo como servidor de herramientas para agentes.

    No necesitaba reescribir nada. Necesitaba un archivo de 40 líneas.

    Ese es el malentendido más caro que veo ahora mismo entre developers senior: creer que las herramientas de un agente son una infraestructura nueva. No lo son. Tu API de negocio ya es el servidor de herramientas. Lo que falta es un adaptador delgado que traduzca entre el modelo y tus endpoints.

    Y sí, existe MCP: un estándar para exponer herramientas de forma interoperable. Aquí vamos por debajo, a la mecánica cruda, para que veas exactamente qué pasa entre el modelo y tu servidor. Si después quieres estandarizar y exponer estas mismas herramientas para cualquier cliente, eso ya lo cubrí aquí.

    Paso 1: el servidor de herramientas que no sabe nada de IA

    Un servidor de herramientas para agentes de IA es una API HTTP normal cuyos endpoints se exponen al modelo mediante un adaptador que declara, para cada operación, su schema de entrada y cuándo debe invocarse. No es infraestructura nueva: es tu API de negocio más una capa de traducción.

    Esta es la parte que la gente complica sin motivo. El servidor es una API HTTP normal. Sin SDK de IA. Sin dependencias raras. Sin una sola línea que mencione un modelo.

    Un catálogo de productos con Hono:

    npm install hono @hono/node-server
    
    // server/index.ts
    import { Hono } from "hono";
    import { serve } from "@hono/node-server";
    
    type Producto = {
      sku: string;
      nombre: string;
      categoria: "perifericos" | "monitores" | "audio";
      precio: number;
      stock: number;
    };
    
    const catalogo: Producto[] = [
      { sku: "TEC-65", nombre: "Teclado mecánico 65%", categoria: "perifericos", precio: 89.9, stock: 12 },
      { sku: "MON-27", nombre: "Monitor 27\" 144Hz", categoria: "monitores", precio: 279.0, stock: 3 },
      { sku: "AUD-XM", nombre: "Auriculares ANC", categoria: "audio", precio: 199.0, stock: 0 },
    ];
    
    const app = new Hono();
    
    app.get("/productos", (c) => {
      const categoria = c.req.query("categoria");
      const items = categoria
        ? catalogo.filter((p) => p.categoria === categoria)
        : catalogo;
      return c.json({ items });
    });
    
    app.get("/stock/:sku", (c) => {
      const producto = catalogo.find((p) => p.sku === c.req.param("sku"));
      if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
      return c.json({ sku: producto.sku, stock: producto.stock, precio: producto.precio });
    });
    
    app.post("/pedido", async (c) => {
      const { sku, unidades } = await c.req.json<{ sku: string; unidades: number }>();
      const producto = catalogo.find((p) => p.sku === sku);
      if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
      if (producto.stock < unidades) return c.json({ error: "Stock insuficiente" }, 409);
    
      producto.stock -= unidades;
      return c.json({ pedidoId: crypto.randomUUID(), sku, unidades, total: producto.precio * unidades });
    });
    
    serve({ fetch: app.fetch, port: 3000 });
    

    Léelo otra vez y busca la palabra "IA". No está.

    Esto importa más de lo que parece. Cuando mezclas la lógica de negocio con la capa del modelo, acabas con endpoints que solo sirven para el agente, imposibles de testear en aislamiento y que se rompen cada vez que cambias de proveedor.

    Manteniendo la separación, tu API sigue sirviendo a tu web, a tu app móvil y al agente. Tres consumidores, una fuente de verdad.

    Paso 2: el adaptador que convierte tu API en servidor de herramientas

    Ahora sí, la capa que traduce. Instalas el SDK:

    npm install @anthropic-ai/sdk zod
    

    Y defines las herramientas con betaZodTool, que te deja declarar el schema de entrada con Zod y la función que se ejecuta cuando el modelo pide esa herramienta:

    // agent/tools.ts
    import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
    import { z } from "zod";
    
    const API = "http://localhost:3000";
    
    export const buscarProductos = betaZodTool({
      name: "buscar_productos",
      description:
        "Devuelve el catálogo de productos, opcionalmente filtrado por categoría. " +
        "Llama a esto cuando el usuario pregunte qué productos hay disponibles, " +
        "pida recomendaciones o mencione una categoría concreta.",
      inputSchema: z.object({
        categoria: z
          .enum(["perifericos", "monitores", "audio"])
          .optional()
          .describe("Categoría por la que filtrar. Omítela para ver el catálogo completo."),
      }),
      run: async ({ categoria }) => {
        const url = categoria ? `${API}/productos?categoria=${categoria}` : `${API}/productos`;
        const res = await fetch(url);
        return JSON.stringify(await res.json());
      },
    });
    
    export const consultarStock = betaZodTool({
      name: "consultar_stock",
      description:
        "Devuelve stock y precio actuales de un SKU. " +
        "Llama a esto SIEMPRE antes de confirmar disponibilidad o precio a un usuario. " +
        "Nunca respondas de memoria sobre stock o precios.",
      inputSchema: z.object({
        sku: z.string().describe("Identificador del producto, por ejemplo TEC-65"),
      }),
      run: async ({ sku }) => {
        const res = await fetch(`${API}/stock/${sku}`);
        if (!res.ok) return `No existe ningún producto con SKU ${sku}`;
        return JSON.stringify(await res.json());
      },
    });
    

    Fíjate en las descripciones. No dicen solo qué hace la herramienta: dicen cuándo llamarla.

    Esto no es cosmética. Los modelos Opus recientes son conservadores pidiendo herramientas — si dudan, prefieren responder ellos. Una descripción prescriptiva del tipo "llama a esto siempre antes de confirmar precios" convierte una llamada probable en una llamada determinista. Una descripción como "consulta el stock" la deja al azar.

    Y el enum en categoria hace algo que nadie agradece hasta que falla: elimina de raíz que el modelo invente "periféricos" con tilde, "peripherals" o "teclados". Si un parámetro tiene un conjunto cerrado de valores, dilo en el schema. Aquí es donde Zod deja de ser una librería de validación y se convierte en el contrato entre el modelo y tu API — si quieres exprimir esa parte, la trabajo a fondo en el curso de Zod.

    Paso 3: el agente

    Aquí viene lo que te ahorra casi todo el código que la gente escribe a mano.

    crearPedido es la tercera herramienta y la dejo para el siguiente apartado, porque tiene truco:

    // agent/index.ts
    import Anthropic from "@anthropic-ai/sdk";
    import { buscarProductos, consultarStock, crearPedido } from "./tools";
    
    const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
    
    const finalMessage = await client.beta.messages.toolRunner({
      model: "claude-opus-4-8",
      max_tokens: 16000,
      tools: [buscarProductos, consultarStock, crearPedido],
      messages: [
        {
          role: "user",
          content: "¿Qué monitores tenéis? Si hay stock del de 27 pulgadas, pídeme dos.",
        },
      ],
    });
    
    console.log(finalMessage.content.find((b) => b.type === "text")?.text);
    

    Eso es el agente entero.

    toolRunner ejecuta el bucle agéntico completo: llama a la API, detecta que la respuesta trae bloques tool_use, ejecuta tu función run, devuelve el resultado como tool_result, y repite hasta que el modelo deja de pedir herramientas. Es beta y vive bajo client.beta.messages. El comportamiento completo está documentado en la guía oficial de tool use.

    Un detalle que se come a mucha gente: ese archivo usa await en el nivel superior, así que necesitas "type": "module" en tu package.json o no compila.

    Y tres cambios de la API actual que rompen código copiado de tutoriales viejos:

    Parámetro Estado en Opus 4.8 Qué usar
    temperature, top_p, top_k Eliminados — devuelven 400 Nada. Quítalos
    budget_tokens Ya no existe thinking: { type: "adaptive" }
    Control de esfuerzo output_config: { effort: "high" } (low a max)

    Si arrastras un temperature: 0 de un proyecto de 2024, tu agente no arranca. Y con thinking: adaptive el modelo decide cuánto piensa según la dificultad, en lugar de gastarte un presupuesto fijo en preguntas triviales.

    Si prefieres el bucle manual, puedes escribirlo, pero recuerda volcar el response.content completo al historial para preservar los bloques tool_use, y devolver cada tool_result con su tool_use_id. Los stop_reason que verás son end_turn, tool_use, max_tokens, pause_turn y refusal.

    Las herramientas destructivas van gateadas

    Una herramienta destructiva se gatea devolviendo el control al usuario dentro de la propia función run, antes del efecto secundario. No requiere bajar al bucle manual.

    Esto es lo que separa una demo de algo que puedes poner delante de un usuario.

    Tu herramienta crear_pedido cobra dinero. Enviar un email, borrar un registro o lanzar un despliegue son irreversibles. El error más común que veo es asumir que para meter aprobación humana hay que bajar al bucle manual.

    No hace falta. El gate vive dentro de la propia función run:

    // agent/tools.ts — mismo archivo, mismos imports
    export const crearPedido = betaZodTool({
      name: "crear_pedido",
      description:
        "Crea un pedido real y descuenta stock. Llama a esto solo cuando el usuario " +
        "haya confirmado explícitamente sku y cantidad.",
      inputSchema: z.object({
        sku: z.string().describe("SKU del producto"),
        unidades: z.number().int().positive().describe("Número de unidades"),
      }),
      run: async ({ sku, unidades }) => {
        const aprobado = await pedirConfirmacion(
          `¿Confirmas el pedido de ${unidades} x ${sku}?`
        );
        if (!aprobado) return "El usuario canceló el pedido. No se ha creado nada.";
    
        const res = await fetch(`${API}/pedido`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ sku, unidades }),
        });
        return JSON.stringify(await res.json());
      },
    });
    

    pedirConfirmacion es tuya. En un CLI son cinco líneas:

    import { createInterface } from "node:readline/promises";
    
    async function pedirConfirmacion(pregunta: string): Promise<boolean> {
      const rl = createInterface({ input: process.stdin, output: process.stdout });
      const respuesta = await rl.question(`${pregunta} (s/n) `);
      rl.close();
      return respuesta.trim().toLowerCase().startsWith("s");
    }
    

    En una app real es un modal, un mensaje de Slack o una fila en una tabla de aprobaciones pendientes. Da igual cuál: el contrato es el mismo, la función run se queda esperando.

    Y devolver "El usuario canceló el pedido" es una respuesta perfectamente válida para el modelo. La entiende, se detiene y se lo explica al usuario. No hace falta arquitectura extra.

    Dos cosas más que conviene saber. El modelo puede pedir varias herramientas en el mismo turno, y sus resultados vuelven todos juntos en un único mensaje de usuario — así que tus funciones run deben ser seguras ejecutándose en paralelo.

    Y si necesitas que el input valide exactamente contra tu schema, sin campos de más, existe strict: true a nivel de definición de herramienta. Ojo: opera sobre el JSON Schema final, que debe llevar additionalProperties: false y required bien puestos — si defines con Zod, revisa el schema que genera antes de activarlo.

    Menos herramientas, mejor descritas

    Cuando pasas de cinco herramientas, la calidad se desploma antes por descripciones vagas que por número.

    Un agente con tres herramientas que dicen con precisión cuándo usarse rinde mejor que uno con quince que dicen qué hacen. Si tienes cuatro endpoints que devuelven variantes de lo mismo, agrúpalos en una herramienta con un parámetro enum. El modelo elige mucho mejor entre valores de un enum que entre nombres de herramientas parecidos.

    Esto es diseño de interfaz, no prompting. Y como cualquier diseño de interfaz, se define antes de escribir el código — es exactamente el trabajo que describo en el libro de Spec-Driven Development: decidir el contrato antes que la implementación.

    Qué hacer hoy

    Abre tu API de siempre. Elige los tres endpoints que más consultas de usuario resolverían. Escribe un archivo tools.ts que los envuelva, con descripciones que digan cuándo llamarlos. Conéctalo al toolRunner.

    Tienes un agente funcionando esta tarde, sin tocar una línea de tu backend.

    Ese es el punto entero de este post: no construyes herramientas para IA, construyes una API normal y le pones un adaptador. Todo lo demás — el estándar, el transporte, el registro de herramientas — son decisiones que vienen después, cuando ya sabes qué herramientas necesitas de verdad.

    Si aún estás decidiendo qué piezas montar alrededor del agente, el stack de IA agéntica que uso en 2026 cubre las decisiones de infraestructura que vienen justo después de este archivo.

    Y si prefieres trabajarlo con otros developers que están en el mismo punto, en Dominicode Labs tenemos los proyectos y los patrones que usamos en producción.


    Preguntas frecuentes

    ¿Necesito MCP para conectar un agente a mi API?
    No. MCP es un estándar de interoperabilidad, útil cuando quieres que varios clientes distintos consuman tus mismas herramientas. Para un agente propio consumiendo tu propia API, un adaptador con betaZodTool y el tool runner del SDK es suficiente y tiene mucha menos superficie que mantener. Si tu caso sí es ese —varios clientes distintos consumiendo las mismas herramientas—, el montaje completo está en MCP Server en TypeScript.

    ¿Qué modelo debo usar para un agente con herramientas?
    claude-opus-4-8 es el modelo actual y más capaz de la familia Opus. Evita los identificadores con sufijo de fecha de generaciones anteriores: están retirados o desactualizados y el código que los usa deja de funcionar.

    ¿Por qué me da error 400 al enviar temperature?
    Porque en Opus 4.8 temperature, top_p y top_k están eliminados. Enviarlos devuelve un error. Para controlar el razonamiento usa thinking: { type: "adaptive" } y, si necesitas más esfuerzo, output_config: { effort: "high" }.

    ¿Cómo evito que el agente ejecute acciones destructivas sin permiso?
    Metiendo el gate dentro de la función run de la herramienta: pides confirmación y, si el usuario dice que no, devuelves un string tipo "el usuario canceló". No necesitas bajar al bucle manual para tener aprobación humana, que es el error habitual.

    ¿El modelo puede llamar a varias herramientas a la vez?
    Sí. Puede pedir varias en un mismo turno y sus resultados vuelven juntos en un único mensaje de usuario. Diseña tus funciones run para que sean seguras ejecutándose en paralelo.

    ¿Debo montar el servidor de herramientas aparte de mi API?
    No hace falta. Tu API de negocio ya es el servidor; la capa de tools es un cliente HTTP delgado que vive en el proceso del agente. Mantener esa separación te permite servir a tu web, tu app y tu agente desde la misma fuente de verdad.


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

  • ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    Cada cierto tiempo alguien me escribe con la misma pregunta: "Bezael, tengo que montar una API en Node. ¿Express pelado o NestJS?".

    Y la respuesta honesta durante años ha sido incómoda. Express te deja solo: sin DI, sin estructura, sin ciclo de vida. NestJS te da todo eso, pero a cambio de decoradores por todas partes y una curva que a un junior le cuesta semanas.

    En medio no había casi nada. Ese hueco es exactamente donde vive ExpressoTS 4.0, publicada el 17 de julio de 2026, casi veinte meses después de la v3, y que por primera vez no parece un proyecto experimental.

    Ojo con lo que voy a decir y con lo que no voy a decir. No voy a contarte que esto reemplaza a NestJS. No lo hace. El ecosistema de NestJS es incomparablemente más maduro: integraciones, documentación, gente que ya resolvió tu problema hace dos años. Lo que sí ha cambiado es que ahora existe una alternativa seria para quien quiere estructura sin cargar con todo el peso.

    Qué es ExpressoTS y por qué la 4.0 importa

    ExpressoTS es un framework TypeScript para backend sobre Node.js. Trae inyección de dependencias con contenedor IoC, routing, middleware, hooks de ciclo de vida, manejo de errores y bootstrap de la aplicación.

    Hasta la v3 era, básicamente, "Express con DI y decoradores". Útil, correcto, poco ambicioso.

    La 4.0 es otra cosa. Requiere Node.js >= 20.19.0, según las release notes oficiales de la v4.0.0, y trae bloques que hasta ahora te tocaba construir tú o importar de tres librerías distintas.

    Dónde queda cada uno, para que no tengas que deducirlo:

    Express ExpressoTS 4.0 NestJS
    Inyección de dependencias No Sí, contenedor IoC Sí, contenedor IoC
    Curva de aprendizaje Baja Media Alta
    Boilerplate Mínimo Moderado Alto
    Testing incluido No Sí, createTestApp()
    Observabilidad incluida No Sí, Studio local Vía integraciones
    Madurez del ecosistema Muy alta Baja Muy alta

    Voy a ir a los bloques que de verdad cambian cómo escribes el código.

    Interceptors: AOP sin montarte tu propio framework

    Los interceptors de ExpressoTS 4.0 aplican cross-cutting concerns —caching, reintentos, transformación de respuestas— con ejecución condicional declarativa: la condición vive en el decorador, no dentro del interceptor.

    Este es el titular de la release, y no había visto la idea tan limpia en otros frameworks del ecosistema.

    // PerformanceInterceptor viene incluido. CacheInterceptor lo escribes tú.
    @UseInterceptors(
        PerformanceInterceptor,
        whenInterceptor(
            (ctx) => ctx.request.headers["x-cache"] === "true",
            CacheInterceptor,
        ),
    )
    

    Léelo despacio. El PerformanceInterceptor corre siempre. El CacheInterceptor corre solo si la request trae esa cabecera. No hay un if dentro del interceptor decidiendo si le toca trabajar o no. La condición vive fuera, en la declaración.

    Parece un detalle estético. No lo es. La cantidad de código que he visto en producción donde un interceptor empieza con seis líneas de "¿me toca actuar ahora?" es descorazonadora. Ahí es donde nacen los bugs de "en staging cachea y en prod no".

    Tienes también unlessInterceptor() para el caso inverso, auto-discovery por decoradores y tres interceptors listos de fábrica: LoggingInterceptor, PerformanceInterceptor y TimeoutInterceptor.

    Y para componerlos, dos utilidades que redondean el argumento: pipeInterceptors() los encadena en orden, y combineInterceptors() los lanza en paralelo para trabajo de solo efecto secundario, como logging o métricas.

    Eventos tipados con prioridad

    El sistema de eventos de ExpressoTS 4.0 combina auto-discovery de handlers, routing condicional y ejecución por prioridad, con tipado end-to-end vía IEventHandler<T>:

    @OnEvent(UserCreatedEvent, { priority: 1 })
    export class SendWelcomeEmailHandler implements IEventHandler<UserCreatedEvent> {
        handle(event: UserCreatedEvent) { /* ... */ }
    }
    

    El IEventHandler<UserCreatedEvent> es lo que hace que esto valga la pena. En cuanto alguien cambie la forma del evento, el compilador te avisa en todos los handlers.

    Sin eso, un sistema de eventos es una lista de strings mágicos esperando a romperse en el peor momento.

    Configuración type-safe y validación pluggable

    La configuración se declara con validación y variación por entorno:

    export default defineConfig({
        database: {
            url: Env.string("DATABASE_URL", { required: true }),
        },
    });
    

    Si falta DATABASE_URL, la aplicación no arranca. No revienta a los veinte minutos en la primera query. Falla en el arranque, que es donde debe fallar.

    Y esto conecta con una decisión de diseño que aplaudo: la Smart Validation de v4 usa un registry de adapters pluggable. Soporta class-validator, Zod y Yup. No te casan con una librería.

    Si vas a montar algo nuevo con esto, mi recomendación es Zod. Esquema y tipo en la misma declaración, sin decoradores, sin duplicar la forma del dato en dos sitios. Y si te preocupa el coste de tipado en un proyecto grande, ese problema tiene fecha de caducidad: ya conté cómo el compilador de TypeScript reescrito en Go cambia el juego.

    Si nunca has llevado Zod más allá de z.object(), en el curso de Zod para TypeScript cubro justo la parte que la gente se salta: transforms, refinements y validación en los bordes del sistema.

    ExpressoTS Studio: local, no cloud

    ExpressoTS Studio es una plataforma de desarrollo local: no envía tu tráfico a ningún servidor externo. Es la decisión más valiente de la release y merece sección propia.

    Te da un dashboard de estado, un mapa de arquitectura generado en vivo desde el grafo de dependencias, un request timeline con spans de OpenTelemetry, logs en directo, inspección de errores, replay de tráfico y una auditoría de seguridad con scoring basada en el tráfico real de tu entorno de desarrollo.

    El mapa de arquitectura generado desde el grafo DI es la parte que más me gusta. Documentación de arquitectura que no se queda obsoleta porque nadie la actualiza: se deriva del código.

    Y que sea local en vez de SaaS elimina de golpe la conversación con legal antes de empezar.

    El resto, en corto

    Hay más, y no todo necesita párrafos:

    • Lazy-loading de módulos con rutas auto-detectadas desde @controller() y preload hints (high, medium, low, never). Debería mejorar los cold starts, que en serverless es dinero.
    • Módulo de testing con createTestApp() a cero configuración, API fluida para HTTP, snapshot testing y load testing con métricas de percentiles.
    • Logging de 11 fases: structured logging, transports a fichero, gestión de contexto, consulta de logs y export a Markdown.
    • Guards por rol, por permiso y resource-owner, con utilidades de composición.
    • Health monitoring en tres capas: middleware pipeline, providers IHealthCheck y dashboard agregado.
    • Content negotiation RFC 7231: JSON, XML, CSV y YAML.
    • Scopes DI personalizados: tenant, transaction, workflow, session. Si haces multi-tenant, esto te ahorra un patrón entero.
    • API versioning por URL con el decorador @Version().
    • Errores RFC 7807 (problem details) con exception filters y sugerencias de ruta en los 404.
    • Lifecycle hooks: globalConfiguration(), configureServices(), postServerInitialization(), serverShutdown().

    Si vienes de v3: lo que se rompe

    Migrar de v3 a v4 tiene tres breaking changes obligatorios:

    1. Los patrones de DI cambian. Revísalos uno a uno.
    2. Los lifecycle hooks de app.ts hay que actualizarlos.
    3. Sube el runtime a Node.js >= 20.19.0.

    En soporte, el equipo ha sido razonable: según su política publicada, v4.0.0 recibe 24 meses de bugfixes y parches de seguridad. La v3.x tenía 18 meses y los han extendido hasta diciembre de 2026 para que la migración no sea una carrera.

    Una migración así es un caso de manual para trabajar con especificación antes que con código: describes el estado destino, listas los puntos de cambio y solo entonces dejas que un agente te ayude a ejecutarlo módulo a módulo. Es la metodología que documenté en el libro de Spec-Driven Development, y funciona especialmente bien cuando el cambio es amplio pero mecánico.

    Mi veredicto

    ExpressoTS 4.0 no es un juguete. Interceptors condicionales, eventos tipados, scopes DI de tenant y transaction, Studio local: son decisiones de gente que ha sufrido aplicaciones grandes.

    ¿Lo llevaría a un proyecto crítico, con equipo de quince personas y entrega en tres meses? Todavía no. NestJS tiene ecosistema, integraciones probadas y una comunidad enorme, y eso pesa más que cualquier feature bonita cuando algo te falla un viernes.

    ¿Lo usaría en un servicio nuevo, un side project o una API interna donde el peso y los cold starts importan? Sin dudarlo.

    Instálalo y móntate algo pequeño esta semana:

    npx @expressots/cli new my-app
    

    Levanta Studio, mira el mapa de arquitectura que genera del grafo DI y decide con tu propio código delante. Media hora te basta para saber si te encaja. La documentación oficial está sorprendentemente bien para un proyecto de este tamaño.

    Y si quieres ver cómo integro frameworks nuevos como este en un flujo de trabajo con agentes de IA —specs primero, implementación asistida, tests de verdad— eso es justo lo que describo en mi stack de IA agéntica y lo que practicamos dentro de Dominicode Labs.

    Preguntas frecuentes

    ¿ExpressoTS 4.0 sustituye a NestJS?

    No, y no lo pretende. NestJS tiene un ecosistema mucho más maduro en integraciones, documentación y comunidad. ExpressoTS ocupa el hueco entre Express pelado y NestJS: te da DI, ciclo de vida y estructura con menos boilerplate y una curva más corta. Son opciones distintas, no una sustitución.

    ¿Qué diferencia hay entre ExpressoTS y Express?

    Express es un router HTTP minimalista: no trae inyección de dependencias, ni estructura de proyecto, ni ciclo de vida de aplicación. ExpressoTS se construye sobre esa base y añade contenedor IoC, decoradores para controllers, hooks de ciclo de vida, guards, interceptors y utilidades de testing. Con Express decides tú toda la arquitectura; con ExpressoTS parte ya viene decidida.

    ¿Qué versión de Node necesito para ExpressoTS 4.0?

    Node.js 20.19.0 o superior, según las release notes oficiales de la v4.0.0 (17 de julio de 2026). Es un breaking change respecto a v3, así que verifica el runtime de tu entorno de despliegue antes de migrar.

    ¿ExpressoTS Studio envía mis datos a la nube?

    No. Studio es una plataforma de desarrollo local. El dashboard, el mapa de arquitectura, el request timeline con spans OpenTelemetry, los logs y la auditoría de seguridad funcionan sobre el tráfico de tu entorno de desarrollo, en tu máquina.

    ¿Puedo usar Zod para validar en ExpressoTS 4.0?

    Sí. La Smart Validation de v4 funciona con un registry de adapters pluggable que soporta class-validator, Zod y Yup. Puedes elegir la librería que ya uses en el resto del proyecto.

    ¿Cuánto tiempo tengo para migrar desde v3?

    El soporte de v3.x se ha extendido hasta diciembre de 2026. La v4.0.0 recibe 24 meses de bugfixes y parches de seguridad desde su publicación en julio de 2026. Tienes margen para planificar la migración sin prisas.

    ¿Qué se rompe al migrar de ExpressoTS v3 a v4?

    Tres cosas: los patrones de inyección de dependencias cambian y hay que revisarlos uno a uno, los lifecycle hooks de app.ts necesitan actualizarse, y el runtime debe subir a Node.js 20.19.0 o superior.

    ¿ExpressoTS 4.0 sirve para serverless?

    Es uno de los escenarios donde mejor encaja. El lazy-loading de módulos carga solo lo necesario en cada invocación, con preload hints (high, medium, low, never) para afinar qué se precarga, lo que ayuda con los cold starts. Súmale que el core es ligero comparado con alternativas más pesadas del ecosistema.


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

  • Novedades de ECMAScript 2026 (ES17) en JavaScript

    Novedades de ECMAScript 2026 (ES17) en JavaScript

    Si llevas unos años programando en JavaScript, seguro que has tenido que lidiar con el clásico error de precisión aritmética: intentar sumar 0.1 y 0.2 en la consola de tu navegador y ver cómo devuelve 0.30000000000000004.

    Durante décadas, la respuesta de la comunidad ha sido la misma: "Es cosa del estándar IEEE 754 de coma flotante, acéptalo, redondea a mano o usa una librería externa".

    Por fin, Ecma International ha decidido poner fin a este y otros parches históricos con la aprobación oficial de ECMAScript 2026 (ES17).

    Hoy te quiero enseñar las características más importantes de este nuevo estándar que cambiarán tu forma de escribir JavaScript en tu día a día, y cuáles son las esperadas funciones que se han quedado a las puertas.


    Las características estrella de ES2026

    La versión 17 de la especificación oficial se centra en cerrar brechas históricas de la ergonomía del lenguaje, manipulación de datos binarios y control de errores:

    1. Math.sumPrecise (Suma exacta de flotantes)

    Se acabó el usar librerías externas o el típico truco de multiplicar por 100 y luego dividir solo para sumar decimales. El nuevo método Math.sumPrecise recibe un iterable de números y realiza una suma compensando matemáticamente las pérdidas de precisión de la coma flotante.

    const valores = [0.1, 0.2];
    // JavaScript tradicional: valores[0] + valores[1] => 0.30000000000000004
    const totalExacto = Math.sumPrecise(valores); // => 0.3
    

    2. Error.isError (Validación robusta de excepciones)

    Comprobar si un objeto capturado en un bloque catch es un error real mediante instanceof Error es frágil. Si el error proviene de otro contexto de ejecución (como un iframe en el navegador o un Web Worker en Node/Deno), la validación suele fallar. Error.isError soluciona esto aportando un chequeo universal y fiable a nivel interno del motor JS.

    3. Codificación nativa Hex y Base64 en Uint8Array

    Hasta ahora, convertir datos binarios a texto hexadecimal o Base64 requería funciones auxiliares complejas (Buffer.toString en Node o btoa/atob en navegador). ES2026 introduce métodos nativos directamente en el prototipo de Uint8Array para realizar conversiones de forma directa y de altísimo rendimiento.

    4. Valores por defecto en Mapas (Map.prototype)

    Se añaden nuevos métodos a Map y WeakMap para poder insertar un valor por defecto si una clave no existe en el mapa, simplificando la escritura de cachés o contadores.

    const visitas = new Map();
    // Inserta 1 si la clave no existe, o incrementa el valor actual
    visitas.getOrInsert("usuario_123", 0);
    

    5. Array.fromAsync e Iterator.concat

    Trabajar con generadores y flujos asíncronos ahora es mucho más ergonómico. Array.fromAsync permite construir un array a partir de iterables asíncronos de forma limpia, mientras que Iterator.concat facilita la unión de múltiples secuencias sin necesidad de cargarlas completas en memoria.


    Lo que se queda fuera (Las ausencias destacadas)

    A pesar de las altas expectativas que había durante su fase de desarrollo, algunas de las propuestas más esperadas no lograron entrar en la especificación final aprobada este año:

    • Temporal API (El nuevo Date): Aunque la comunidad lleva años demandando un reemplazo moderno, consistente y no mutable para el desastroso objeto Date tradicional, la API de Temporal sigue en revisión técnica activa y no forma parte del estándar oficial de 2026.
    • Explicit Resource Management (using): La esperada sintaxis de liberación automática de recursos (similar a la que encontramos en lenguajes como C# o TypeScript con la palabra clave using) tampoco logró cerrarse a tiempo para esta edición.

    Escribe código preparado para el futuro

    La evolución de JavaScript demuestra una clara tendencia a madurar el lenguaje, absorbiendo utilidades que antes requerían librerías de terceros (como Lodash o parches de buffer binario). Como vimos en nuestro post sobre oMLX y TypeScript en Mac, escribir código nativo limpio sobre los frameworks optimizados es la clave de la eficiencia en el desarrollo moderno.

    Adoptar las mejores prácticas de TypeScript avanzado y escribir código nativo limpio es exactamente el enfoque de calidad de software que defendemos en el curso de Construye con IA para estructurar aplicaciones robustas de producción.


    Conclusión: Simplifica tu código

    No sigas arrastrando dependencias externas para resolver tareas de precisión matemática básica o conversiones binarias. Revisa la documentación de ECMAScript 2026 y aprovecha las nuevas capacidades integradas del motor de JavaScript para limpiar tu código y mejorar la velocidad de tus ejecuciones.

    Si quieres debatir sobre el futuro de las APIs de JavaScript, patrones de TypeScript avanzado y compartir configuraciones de compilación con otros desarrolladores senior, te espero en Dominicode Labs.


    Preguntas Frecuentes (FAQ)

    ¿Cuándo podré usar las funciones de ES2026 en navegadores?

    La especificación ya ha sido aprobada de forma oficial. Los motores principales (V8 de Chrome/Node, JavaScriptCore de Safari y SpiderMonkey de Firefox) ya han implementado la mayoría de estas funciones de forma de forma experimental. Puedes utilizarlas hoy mismo actualizando tus entornos de desarrollo de Node.js o mediante polyfills si necesitas soporte para navegadores antiguos.

    ¿Cómo ayuda Math.sumPrecise con el rendimiento de arrays grandes?

    Math.sumPrecise está optimizado a nivel de compilación nativa en C++ en los motores de los navegadores. Al delegar la suma compensada de precisión directamente al motor en lugar de ejecutar un bucle con lógica de redondeo en JavaScript, la velocidad de procesamiento de grandes volúmenes de datos numéricos mejora sustancialmente.

    ¿Por qué instanceof Error falla en iframes y Web Workers?

    Porque cada iframe o Web Worker crea un contexto de ejecución global diferente (un Realm distinto) con su propio constructor Error. Por lo tanto, un error lanzado dentro de un iframe no se reconoce como instancia del objeto Error de la ventana principal de navegación, problema que resuelve el nuevo chequeo estático Error.isError.

    ¿Qué sucederá con la API de Temporal?

    La API de Temporal sigue estando en fase activa de desarrollo de Stage 3/4. Es una especificación sumamente compleja debido a la gestión de zonas horarias y calendarios. Es muy probable que se apruebe formalmente para la próxima iteración del estándar (ECMAScript 2027).


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