← Blog
·10 blog.minutes

TypeScript Moderno: Patrones y Prácticas para 2026

Desde la configuración estricta hasta clientes API type-safe: los patrones de TypeScript que separan el código de producción de los proyectos de juguete en 2026.

TypeScript ha evolucionado más rápido de lo que la mayoría de los ecosistemas pueden seguir. Cada versión trae nueva sintaxis, comprobaciones más estrictas y patrones que reescriben lo que se considera idiomático. La diferencia entre código TypeScript que meramente compila y código que realmente aprovecha el sistema de tipos es enorme, y esa brecha determina si tus tipos son documentación o ruido.

Este artículo cubre los patrones que más importan para TypeScript de producción en 2026. No son ejercicios académicos. Son las prácticas que hacen que las bases de código grandes sean más seguras, las APIs más difíciles de usar incorrectamente y la refactorización menos aterradora. Cada ejemplo proviene de patrones reales utilizados en sistemas de producción que manejan millones de solicitudes.

El Fundamento: Configuración de TypeScript para 2026

La decisión más impactante sobre la calidad de TypeScript no está en tu código, sino en tu tsconfig.json. La línea de base ha superado strict: true. En 2026, una configuración de producción debe habilitar comprobaciones que eran optativas o experimentales en versiones anteriores.

// tsconfig.json — the 2026 baseline
{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Cada flag elimina una clase de bugs. exactOptionalPropertyTypes evita el error común donde ?: string | undefined se asigna undefined explícitamente: la distinción entre ausente y presente-pero-undefined importa en los límites de API. noUncheckedIndexedAccess te obliga a manejar undefined para cada acceso a objeto con una clave dinámica, lo que detecta caídas en tiempo de ejecución antes de que lleguen a producción. verbatimModuleSyntax asegura que la resolución de módulos coincida con el runtime, eliminando los desajustes silenciosos que rompen proyectos ESM en producción.

Los equipos que adoptan esta configuración reportan significativamente menos incidentes de producción relacionados con referencias null y propiedades undefined. La sobrecarga de manejar esas comprobaciones extra de undefined por adelantado es trivial comparada con depurar una caída porque una respuesta de API faltaba un campo que asumías que existía.

Uniones Discriminadas — Tu Patrón Más Poderoso

Las uniones discriminadas son el patrón más impactante en TypeScript. Modelan estados explícitamente, hacen que los estados ilegales no sean representables y le dan al compilador la información que necesita para forzar el manejo exhaustivo. Si no las estás usando para cualquier dato que tenga múltiples formas, estás luchando contra el sistema de tipos en lugar de dejar que trabaje para ti.

type ApiState<S, E = Error> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: S }
  | { status: "error"; error: E };

// Exhaustive match — if you add a status, this breaks at compile time
function renderState<S>(state: ApiState<S>): string {
  switch (state.status) {
    case "idle":
      return "Awaiting input";
    case "loading":
      return "Loading...";
    case "success":
      return `Got ${JSON.stringify(state.data)}`;
    case "error":
      return `Failed: ${state.error.message}`;
  }
}

// Usage — impossible to access data on a loading state
const userState: ApiState<User> = { status: "loading" };
// userState.data — does not compile
// userState.status — narrows correctly after any check

La magia está en la reducción (narrowing). Cuando verificas state.status === "success", TypeScript sabe exactamente en qué variante estás y proporciona el tipo correcto para cada campo de esa rama. Esto elimina categorías enteras de comprobaciones defensivas que el código en tiempo de ejecución necesitaría de otro modo. El compilador se convierte en tu suite de pruebas para transiciones de estado inválidas.

Para que este patrón funcione efectivamente, la propiedad discriminante (status en el ejemplo anterior) debe ser un tipo literal, no un string general. Cada variante debe tener un valor literal único. El compilador usa ese literal para discriminar entre ramas, y te advertirá si dos variantes tienen el mismo valor discriminante.

  • Usa siempre un string literal o número como discriminante, nunca un tipo string genérico.
  • Mantén las propiedades compartidas al mínimo. Todo lo que difiere entre variantes pertenece dentro del objeto de la variante.
  • Combina con el tipo never para comprobaciones de exhaustividad: declara una variable de tipo never en la rama default de un switch para detectar casos no manejados en tiempo de compilación.

Tipos Literales de Plantilla y Tipos Marcados

Los tipos literales de plantilla y los tipos marcados resuelven dos problemas distintos que los tipos regulares de TypeScript no pueden abordar. Los tipos literales de plantilla te dan validación de strings a nivel de tipos. Los tipos marcados te dan tipificación nominal en un mundo tipificado estructuralmente: permiten distinguir entre dos valores que tienen la misma forma pero diferentes significados semánticos.

// Template literal type — valid API routes are checked at compile time
type ApiRoute = `/api/${string}`;
type UserRoute = `/api/users/${string}`;

function fetchApi<T>(route: ApiRoute): Promise<T> {
  return fetch(route).then((r) => r.json());
}

// fetchApi("/invalid");       // Error: not assignable
// fetchApi("/api/users/123"); // OK

// Branded type — distinguish IDs that are both strings
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };

function createUserId(id: string): UserId {
  return id as UserId;
}

function getUser(id: UserId): Promise<User> {
  return db.users.find(id);
}

const orderId = "ord_123" as OrderId;
// getUser(orderId); // Error: Type 'OrderId' is not assignable to type 'UserId'

Los tipos literales de plantilla brillan en cualquier sistema donde los strings siguen un formato predecible. Los constructores de rutas API, los generadores de clases CSS, los emparejadores de claves i18n y los sistemas de nombres de eventos se benefician de la validación en tiempo de compilación de valores string. La sintaxis es intuitiva: escribes un patrón con marcadores de posición ${} y TypeScript valida que los valores reales coincidan con ese patrón.

Los tipos marcados resuelven un problema diferente. TypeScript es tipificado estructuralmente, lo que significa que dos tipos con formas idénticas son intercambiables. Esto suele ser una característica, pero se vuelve peligroso cuando tienes IDs que son ambos strings pero representan entidades diferentes. Un UserId y un OrderId no deberían ser intercambiables, incluso si ambos son strings. La intersección con { __brand: "X" } crea un tipo fantasma que solo existe en tiempo de compilación: no tiene costo en tiempo de ejecución.

Los tipos marcados es lo más cerca que TypeScript llega a la tipificación nominal sin herramientas adicionales. Una intersección con una propiedad fantasma puede prevenir la clase de errores donde el ID incorrecto se pasa a la función incorrecta.

El Operador satisfies — Inferencia Sin Sacrificio

Antes de satisfies, los desarrolladores de TypeScript se enfrentaban a un dilema al definir constantes que necesitaban coincidir con un tipo mientras preservaban sus valores literales. Podías anotar el tipo y perder la inferencia estrecha, u omitir la anotación y perder la validación. El operador satisfies elimina esta compensación por completo.

type ColorPalette = {
  primary: string;
  secondary: string;
  accent: string;
};

// Before satisfies — loses literal types
const paletteOld: ColorPalette = {
  primary: "#0f0f0f",  // type is string, not "#0f0f0f"
  secondary: "#ffffff",
  accent: "#0055ff",
};

// After satisfies — validates shape, keeps literals
const palette = {
  primary: "#0f0f0f",  // type is "#0f0f0f"
  secondary: "#ffffff",
  accent: "#0055ff",
} satisfies ColorPalette;

// palette.primary; // type is "#0f0f0f", not string

// Works with complex types too
type EventMap = {
  click: { x: number; y: number };
  focus: { target: string };
  input: { value: string };
};

const handlers = {
  click: (e: { x: number; y: number }) => console.log(e.x, e.y),
  focus: (e: { target: string }) => console.log(e.target),
} satisfies Partial<Record<keyof EventMap, Function>>;

// handlers.click — retains the parameter type from the implementation
// handlers.input — missing, but satisfies allows Partial
// Add input handler later without breaking anything

El operador satisfies es más valioso en objetos de configuración, manejadores de eventos y estructuras de mapeo donde quieres seguridad de tipos en la forma pero necesitas los tipos más estrechos posibles para los valores. Reemplaza una constelación de soluciones alternativas (aserciones as const combinadas con anotaciones de tipo, casts de tipo redundantes y funciones de validación separadas) con una sola palabra clave.

Patrones Genéricos y Clientes API Type-Safe

Los genéricos en TypeScript son fáciles de usar para casos simples (Array<T>, Promise<T>), pero se vuelven verdaderamente poderosos cuando combinas restricciones, tipos condicionales e inferencia en una sola firma de función. La aplicación más práctica de genéricos avanzados es construir clientes API type-safe que eliminan categorías enteras de errores en tiempo de ejecución.

// Type-safe API client
import { z } from "zod";

// Infer the output type from a Zod schema
type InferSchema<T extends z.ZodTypeAny> = T["_output"];

// Route definitions with path params and response schema
type RouteDef = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  params?: Record<string, string>;
  body?: unknown;
};

class ApiClient {
  constructor(private base: string) {}

  get<
    TPath extends string,
    TSchema extends z.ZodTypeAny,
  >(
    path: TPath,
    schema: TSchema,
    ...[params]: TPath extends `${string}:${infer _}/${string}`
      ? [Record<string, string>]
      : TPath extends `${string}:${infer _}`
        ? [Record<string, string>]
        : [Record<string, string>?]
  ): Promise<InferSchema<TSchema>> {
    const resolved = params
      ? Object.entries(params).reduce(
          (p, [k, v]) => p.replace(`:${k}`, v),
          path
        )
      : path;
    return fetch(`${this.base}${resolved}`)
      .then((r) => r.json())
      .then((d) => schema.parse(d) as InferSchema<TSchema>);
  }
}

const api = new ApiClient("https://api.example.com");

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// api.get("/users/:id", userSchema, { id: "123" });
// Result type: { id: string; name: string; email: string }
// If params are required but missing, type error at compile time

El patrón ApiClient combina varias técnicas avanzadas. Los tipos condicionales con infer detectan si la ruta contiene parámetros de ruta y requieren condicionalmente el argumento params. El esquema de respuesta se analiza en tiempo de ejecución con Zod y se infiere en tiempo de compilación, por lo que el tipo de retorno siempre es correcto. Si la API cambia su forma de respuesta, actualizas el esquema y el compilador encuentra todos los consumidores que se rompen.

Este patrón escala a cientos de endpoints sin ceremonia. Cada endpoint es solo un string de ruta y un esquema Zod. La infraestructura genérica maneja el resto: resolución de parámetros de ruta, validación de respuesta e inferencia de tipos. Los equipos que usan este patrón reportan eliminar la mayoría de los errores de integración entre frontend y backend.

  • Combina esquemas Zod o ArkType con wrappers fetch genéricos para seguridad de tipos de extremo a extremo a través del límite de red.
  • Usa tipos condicionales con infer para hacer que los argumentos sean requeridos u opcionales según la estructura de la ruta.
  • Devuelve tipos marcados desde tu cliente API para que los llamantes no puedan intercambiar accidentalmente IDs entre diferentes tipos de entidades.

Patrones de Manejo de Errores y Aumento de Módulos

El manejo de errores es donde la mayoría de las bases de código TypeScript recurren a any y fingen que el problema no existe. El patrón Result, inspirado en Rust y la programación funcional, trae los errores al sistema de tipos para que el compilador exija que cada camino de error sea manejado. El aumento de módulos extiende este enfoque a tipos de terceros, permitiéndote añadir seguridad de tipos a librerías que envían definiciones de tipo incompletas o demasiado permisivas.

// Result type — errors are part of the return type, not thrown

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: UserId): Promise<Result<User, ApiError>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { ok: false, error: await ApiError.fromResponse(res) };
    }
    return { ok: true, value: await res.json() };
  } catch (err) {
    return { ok: false, error: new ApiError("network", String(err)) };
  }
}

// Consumer must handle both branches — compiler enforces it
const result = await fetchUser(userId);
if (result.ok) {
  console.log(result.value.name); // result.value is User
} else {
  console.error(result.error.code); // result.error is ApiError
}

// --- Module augmentation for third-party types ---

// express-session types are incomplete — augment them
declare module "express-session" {
  interface SessionData {
    userId?: string;
    role: "admin" | "user" | "viewer";
    permissions: string[];
  }
}

// Now req.session.role narrows to "admin" | "user" | "viewer"
// Without augmentation, req.session.role would be any

El patrón Result fuerza el manejo explícito de errores en cada punto de llamada. No hay forma de ignorar silenciosamente una operación fallida: el compilador requiere que verifiques result.ok antes de acceder a result.value. Esto elimina el try-catch olvidado que es la fuente de innumerables incidentes de producción. El costo es un poco más de escritura en cada punto de llamada, pero el beneficio es que ningún error queda sin manejar.

El aumento de módulos llena los vacíos donde las definiciones de tipo de terceros se quedan cortas. Muchas librerías populares envían tipos demasiado permisivos: funciones que devuelven any, parámetros tipificados como object o propiedades faltantes en sus interfaces. En lugar de hacer cast con as o usar @ts-ignore, usa declare module "nombre-de-la-librería" en un archivo .d.ts o .ts y añade los tipos faltantes. Las declaraciones se fusionan automáticamente y toda tu base de código se beneficia de la corrección.

La combinación de estos patrones (configuración estricta, uniones discriminadas, tipos literales de plantilla, tipos marcados, satisfies, genéricos type-safe y manejo explícito de errores) representa un enfoque integral para TypeScript en 2026. Cada patrón es útil independientemente, pero juntos crean una base de código donde el compilador detecta errores que de otro modo se convertirían en incidentes de producción. La inversión en aprender estos patrones se amortiza muchas veces en tiempo de depuración reducido, refactorización más segura y APIs que son realmente difíciles de usar incorrectamente.