Type predicates inferidos: la mejora de calidad de vida más grande en años

Esta es probablemente la mejora de calidad de vida más anticipada para desarrolladores TypeScript. Considerar el patrón más común en cualquier codebase:

// Antes de TS 5.5
const values: (string | null)[] = ["hola", null, "mundo", null, "!"];

// Esto DEBERÍA darnos string[], pero no
const filtered = values.filter((v) => v !== null);
// Tipo: (string | null)[], MAL! Acabamos de filtrar los nulls!

// El workaround era este código adicional
const filteredCorrect = values.filter((v): v is string => v !== null);
// Tipo: string[], Correcto, pero boilerplate molesto

En TypeScript 5.5, el compilador infiere automáticamente que (v) => v !== null es un type predicate. Sin anotación:

// TypeScript 5.5
const values: (string | null)[] = ["hola", null, "mundo", null, "!"];

const filtered = values.filter((v) => v !== null);
// Tipo: string[], POR FIN!

// Funciona con patrones más complejos también
interface User {
 id: string;
 name: string;
 email: string | null;
 deletedAt: Date | null;
}

const users: User[] = getUsers();

const activeWithEmail = users.filter((u) => {
 return u.deletedAt === null && u.email !== null;
});

Dónde funciona la inferencia (y dónde no)

La inferencia tiene reglas específicas que se necesita conocer:

// FUNCIONA: sentencia return explícita
const isString = (x: unknown) => {
 return typeof x === "string";
};
// Inferido: (x: unknown) => x is string

// NO FUNCIONA: cuerpo de expresión (sin llaves)
const isStringExpr = (x: unknown) => typeof x === "string";
// Inferido: (x: unknown) => boolean, SIN predicado!
// Esto es porque los cuerpos de expresión podrían tener side effects
// que hacen la inferencia de predicados unsound

// FUNCIONA: chequeos de null/undefined
const isDefined = <T>(x: T | null | undefined) => {
 return x !== null && x !== undefined;
};
// Inferido: (x: T | null | undefined) => x is T

// FUNCIONA: chequeos instanceof
const isDate = (x: unknown) => {
 return x instanceof Date;
};
// Inferido: (x: unknown) => x is Date

// FUNCIONA: chequeos de propiedad
const hasName = (x: object) => {
 return "name" in x;
};
// Inferido: (x: object) => x is object & Record<"name", unknown>

// NO FUNCIONA: condiciones complejas con side effects
let sideEffect = false;
const isBad = (x: unknown) => {
 sideEffect = true; // Side effect previene inferencia
 return typeof x === "string";
};
// Inferido: (x: unknown) => boolean

El gotcha de la migración

Este es un gotcha común de migración. Consideremos código así:

interface ApiResponse {
 data: User | null;
 error: string | null;
}

const responses: ApiResponse[] = await Promise.all(requests);

const successful = responses.filter((r) => r.data !== null);

// Antes: successful[0].data era User | null (sin narrowing)
// Después: TypeScript ahora hace narrowing mejor a través de filter

// El PROBLEMA: código downstream que hacía null checks redundantes
// ahora se marca porque TS sabe que el valor no puede ser null
successful.forEach((r) => {
 if (r.data === null) {
 // TS 5.5: Error! Esta condición siempre será false
 // Porque .filter() ya hizo narrowing
 return;
 }
 processUser(r.data);
});

El fix suele ser remover los null checks ahora redundantes. Pero si teners un codebase grande, eso significa docenas de cambios. Un approach práctico: corregirlos gradualmente con comentarios @ts-expect-error y una regla de lint para limpiarlos con el tiempo.

Isolated declarations: revolución de velocidad de build en monorepos

Esta es la feature que más importa para equipos grandes. isolatedDeclarations es una nueva opción del compilador que requiere que las funciones y valores exportados tengan anotaciones de tipo de retorno explícitas:

// tsconfig.json
{
 "compilerOptions": {
 "isolatedDeclarations": true,
 "declaration": true
 }
}

// SIN isolatedDeclarations (return types implícitos están bien)
export function add(a: number, b: number) {
 return a + b; // Return type inferido como: number
}

// CON isolatedDeclarations (return type explícito requerido)
export function add(a: number, b: number): number {
 return a + b;
}

// Error sin return type explícito:
export function processData(input: string) {
 // ^^^ Error!
 // La función exportada debe tener una anotación de tipo de retorno
 // explícita con --isolatedDeclarations
 const parsed = JSON.parse(input);
 return { ...parsed, processed: true };
}

// Fix: agregar return type explícito
interface ProcessedData {
 processed: true;
 [key: string]: unknown;
}

export function processData(input: string): ProcessedData {
 const parsed = JSON.parse(input);
 return { ...parsed, processed: true };
}

¿Por qué importa? Porque con tipos explícitos en todas las exportaciones, una herramienta puede generar archivos .d.ts para un paquete sin hacer type-checking del paquete entero. Esto significa:

  1. Generación paralela de .d.ts: Cada paquete en tu monorepo puede generar declarations independientemente
  2. Builds incrementales: Solo regenerar declarations para paquetes que cambiaron
  3. Herramientas de terceros: Tools como oxc, swc y esbuild pueden generar archivos .d.ts sin correr el compilador TypeScript completo

Para un monorepo con 23 paquetes, esto baja el tiempo de tsc --build de 94 segundos a 31 segundos.

Estrategia de adopción gradual

No se necesita habilitarlo en todos lados de una:

// packages/shared-utils/tsconfig.json (paquete hoja, comenzar aquí)
{
 "extends": "../../tsconfig.base.json",
 "compilerOptions": {
 "isolatedDeclarations": true
 }
}

// packages/api-client/tsconfig.json (depende de shared-utils)
// Habilitar DESPUÉS de migrar shared-utils
{
 "extends": "../../tsconfig.base.json",
 "compilerOptions": {
 "isolatedDeclarations": true
 }
}

// apps/web/tsconfig.json (app, no necesita habilitarse)
{
 "extends": "../../tsconfig.base.json"
}

Comenzar con tus paquetes hoja (sin dependencias internas), después avanzar hacia adentro. Los paquetes de app no lo necesitan porque no producen declarations.

Config extends: la variable template ${configDir}

TypeScript 5.5 introduce ${configDir} como variable template en paths de tsconfig. Esto resuelve un dolor de larga data en monorepos:

// ANTES: los paths son relativos a la ubicación del config que extiende
// tsconfig.base.json
{
 "compilerOptions": {
 "outDir": "./dist", // Relativo a... ¿qué archivo?
 "rootDir": "./src", // Cambia de significado cuando se extiende
 "paths": {
 "@/*": ["./src/*"] // Se rompe en configs anidados
 }
 }
}

// DESPUÉS: ${configDir} siempre se refiere al directorio del archivo
// tsconfig.base.json
{
 "compilerOptions": {
 "outDir": "${configDir}/dist",
 "rootDir": "${configDir}/src",
 "paths": {
 "@/*": ["${configDir}/src/*"]
 }
 }
}

Ahora cuando packages/api/tsconfig.json extiende ../../tsconfig.base.json, ${configDir} resuelve a packages/api/, no al root. No más paths rotos.

Regex type narrowing

TypeScript 5.5 agrega type narrowing a través de llamadas .test() y .match() de expresiones regulares:

function processInput(value: string | number) {
 if (typeof value === "string") {
 if (/^\d+$/.test(value)) {
 // value sigue siendo string, pero TS ahora sabe que matcheó la regex
 const num = parseInt(value, 10); // TS sabe que esto es seguro
 }
 }
}

// Donde NO funciona
const result = "hola".search(/patron/);
// search devuelve un number, no un boolean, sin narrowing

const replaced = "hola".replace(/h/, "H");
// replace devuelve un string, sin narrowing

Patrones del mundo real que mejoraron

Algunos patrones de un codebase real que mejoraron con la 5.5:

Filtrado de uniones discriminadas

type Event =
 | { type: "click"; x: number; y: number }
 | { type: "keypress"; key: string }
 | { type: "scroll"; delta: number };

const events: Event[] = getEvents();

// Antes de TS 5.5: necesitabas predicado explícito
const clicks = events.filter(
 (e): e is Extract<Event, { type: "click" }> => e.type === "click"
);

// Después de TS 5.5: el predicado se infiere
const clicks = events.filter((e) => {
 return e.type === "click";
});
// Tipo: { type: "click"; x: number; y: number }[]

Array.from con map y filter

const validEntries = Array.from(formData.entries())
 .map(([key, value]) => {
 if (typeof value !== "string") return null;
 if (value.trim() === "") return null;
 return { key, value: value.trim() };
 })
 .filter((entry) => {
 return entry !== null;
 });
// TS 5.5: { key: string; value: string }[]
// TS 5.4: ({ key: string; value: string } | null)[]

Los números de rendimiento

Números reales de una migración de monorepo (23 paquetes, ~180K líneas de TypeScript):

Métrica TS 5.4 TS 5.5 TS 5.5 + isolatedDecl
Build completo 94s 89s 31s
Incremental (1 archivo) 12s 11s 3.2s
Generación declarations 28s 26s 8.1s
Responsividad IDE ~800ms ~650ms ~650ms

La mejora con isolatedDeclarations es el número titular. Pasar de 94s a 31s para un build completo es la diferencia entre "andá a buscar un café" y "ya terminó."

Consideraciones conocidas de actualización

Algunas cosas que vale la pena saber antes de upgradear:

  1. Los predicados inferidos pueden ensanchar tus tipos: Si una función antes devolvía boolean y ahora devuelve x is Foo, código que dependía del return value booleano puede comportarse distinto con el análisis de control de flujo.

  2. isolatedDeclarations + declaration maps: Si usa declarationMap, los maps ahora apuntan al source original incluso sin type checking completo. Asegurarse que tu configuración de source maps sea consistente.

  3. La interacción satisfies + predicados inferidos: satisfies no preserva el predicado inferido. Si teners const isString = ((x: unknown) => typeof x === 'string') satisfies (x: unknown) => boolean, el predicado se pierde.

  4. Regresión de rendimiento con conditional types profundamente anidados: Existe un caso conocido donde la nueva inferencia de tipos de TS 5.5 causó un slowdown de 10x en un archivo específico con cadenas de Omit<Pick<Partial<...>>> muy anidadas. El fix fue simplificar los tipos.

TypeScript 5.5 es una release genuinamente genial. Los type predicates inferidos arreglan uno de los papercuts más comunes del lenguaje. Isolated declarations desbloquean mejoras reales de velocidad de build para monorepos. Y las mejoras de config hacen que las configuraciones compartidas funcionen de verdad. Actualizar con cuidado, esperar algunos errores de tipo que en realidad son bug fixes, y disfrutar el viaje.