learning-front

Extra · Type-level TypeScript (opcional)

strictFunctionTypes y bivarianza

Por qué la sintaxis de método esquiva strictFunctionTypes y se comporta de forma bivariante, el agujero de seguridad que eso abre y cómo la sintaxis de propiedad de función lo cierra.

En el capítulo de varianza viste que los parámetros de los tipos de función son contravariantes cuando tienes strict activado: si Heroe extiende Personaje, una función que acepta Personaje es asignable donde se espera una que acepta Heroe, pero no al revés. TypeScript lo comprueba gracias al flag strictFunctionTypes. Sin embargo, hay una excepción importante que vale la pena conocer: los métodos escritos con la sintaxis de método no se benefician de esa comprobación. Eso es la bivarianza de métodos.

strictFunctionTypes: el flag que activa la comprobación#

strictFunctionTypes es uno de los flags que strict: true activa automáticamente. Su efecto es concreto: hace que TypeScript compruebe los parámetros de los tipos de función de forma contravariante en lugar de bivariante.

Antes de TypeScript 2.6, todos los tipos de función se comprobaban de forma bivariante: tanto la dirección segura como la insegura compilaban sin error. A partir de 2.6, con strictFunctionTypes, solo la dirección segura (contravariante) compila para los tipos de función.

El playground está en strict: true, que es el comportamiento correcto. Si alguna vez ves un proyecto con strictFunctionTypes: false, ese proyecto ha optado por volver a la comprobación bivariante en todos los tipos de función, lo que es un retroceso de seguridad significativo.

El agujero: los métodos son bivariantes#

Aquí está la excepción que el propio equipo de TypeScript documentó al introducir el flag:

Los métodos escritos con sintaxis de método (registrar(x: T): void) no se benefician de strictFunctionTypes. Se comprueban de forma bivariante.

La bivarianza significa que TypeScript acepta ambas direcciones: tanto la segura (contravariante, supertipo del parámetro hacia subtipo) como la insegura (covariante, subtipo hacia supertipo). Con un método, el compilador no te avisa de la dirección insegura.

La diferencia entre método y propiedad de función es cosmética en el código fuente, pero no en el sistema de tipos:

typescript
// Sintaxis de MÉTODO: bivariante (no se beneficia de strictFunctionTypes)
interface ConMetodo<T> {
  registrar(personaje: T): void;
}

// Sintaxis de PROPIEDAD de función: contravariante con strict
interface ConPropiedad<T> {
  registrar: (personaje: T) => void;
}

La diferencia está en si hay : antes de ( y en si aparece => o : para el tipo de retorno. El comportamiento en runtime es idéntico. En el sistema de tipos, la brecha es notable.

Contraste lado a lado#

Pasa el ratón por encima de las declaraciones y observa los errores en rojo. Con la interfaz de método, las dos asignaciones (metodoA y metodoB) compilan sin error aunque una de ellas sea insegura. Con la interfaz de propiedad, solo compila la dirección segura (funA); la insegura (funB) da error con @ts-expect-error esperado.

Por qué existe esta bivarianza#

La razón es la compatibilidad con código existente. Cuando TypeScript introdujo strictFunctionTypes en la versión 2.6, hacer que los métodos también fueran contravariantes habría roto una cantidad considerable de código existente.

El caso más visible son los métodos de Array. Si los métodos fueran contravariantes, código como el siguiente dejaría de compilar:

typescript
// Con strictFunctionTypes en métodos, esto fallaría.
// forEach espera (value: T, ...) => void.
// Pero pasar (p: Personaje) donde se espera (h: Heroe) es contravariante
// y TypeScript lo rechazaría si forEach fuera una propiedad de función.
const heroes: Heroe[] = [];
// Esto compila porque forEach usa sintaxis de método en la lib de TypeScript.
heroes.forEach((p: Personaje) => console.log(p.nombre));

Al preservar la bivarianza para los métodos, TypeScript evita romper ese tipo de código. La decisión es pragmática, no un olvido.

¿Y qué? La consecuencia real#

La bivarianza de los métodos significa que TypeScript no te avisará si asignas una implementación de supertipo donde se espera una de subtipo, siempre que el miembro en cuestión sea un método. El compilador acepta la asignación en silencio, y el error solo aparece en runtime cuando el código intenta acceder a propiedades que no existen.

Un ejemplo concreto: imagina una interfaz genérica de procesador con un método ejecutar(miembro: T): string. Si la usas como tipo de un callback y alguien la instancia con Personaje (supertipo) en lugar de Heroe (subtipo), TypeScript no te dirá nada. Si en algún punto ese callback accede a miembro.rol, obtendrás undefined en runtime.

Si cambias el método a propiedad de función (ejecutar: (miembro: T) => string), ese mismo escenario da un error de compilación. El error no desaparece, solo se mueve hacia donde es útil: al compilador, antes de que el código llegue a ningún lado.

Recomendación práctica: en interfaces genéricas que uses como tipos de callbacks o inyección de dependencias, prefiere la sintaxis de propiedad de función. El coste es cero (la API pública es idéntica), y la ganancia es que strictFunctionTypes hace su trabajo en esos tipos.

Comprueba lo que sabes#

Pregunta 1 de 3

¿Cuál de las dos declaraciones activa la comprobación contravariante de strictFunctionTypes?

Tu turno#

Contrasta la sintaxis de método frente a la propiedad de función usando las interfaces del Team Builder. Observa qué asignaciones acepta o rechaza TypeScript según la sintaxis, y en el tier excelente convierte una interfaz para recuperar la comprobación estricta.

Ejercicio · en esta página

Método vs propiedad de función en el Team Builder

Contrasta la sintaxis de método (bivariante) frente a la sintaxis de propiedad de función (contravariante con strict) usando las interfaces del Team Builder. Cada tier añade una capa de seguridad sobre el anterior.

Paso 1: Observar la bivarianza

  • Sustituyes null! por logPersonaje: la asignación insegura compila porque registrar es un método.
  • Entiendes por qué es insegura en teoría aunque TypeScript no se queje.
  • Verbalizas: con sintaxis de método, TypeScript acepta ambas direcciones.
Ver soluciones
// Solución tier ok: observar la bivarianza en la sintaxis de método.

interface Personaje {
  nombre: string;
}

interface Heroe extends Personaje {
  rol: string;
}

// Interfaz con SINTAXIS DE MÉTODO: RegistradorMetodo<T> es BIVARIANTE.
// strictFunctionTypes no cubre la sintaxis de método — es una excepción deliberada
// de TypeScript por compatibilidad histórica (especialmente con los callbacks de arrays).
interface RegistradorMetodo<T> {
  registrar(personaje: T): void;
}

const logPersonaje: RegistradorMetodo<Personaje> = {
  registrar: (p) => { console.log("Nombre: " + p.nombre); }
};

const logHeroe: RegistradorMetodo<Heroe> = {
  registrar: (h) => { console.log("Nombre: " + h.nombre + " — Rol: " + h.rol); }
};

// Válido: RegistradorMetodo<Personaje> asignable a RegistradorMetodo<Heroe>.
// Esta dirección es INSEGURA (el registro solo lee nombre, pero podría esperar rol),
// pero TypeScript la acepta porque registrar es un MÉTODO (bivarianza).
// En runtime, nada explotaría aquí porque logPersonaje solo usa `nombre`.
// El peligro real es cuando el método del supertipo no accede a las propiedades
// específicas del subtipo pero TypeScript tampoco te avisa si sí lo hiciera.
const registradorBivariante: RegistradorMetodo<Heroe> = logPersonaje;

console.log("Tier ok: con sintaxis de método, TypeScript acepta ambas direcciones (bivarianza).");

Por qué este nivel

  • Con sintaxis de método, TypeScript acepta la asignación insegura sin error. No es un bug del compilador: es una decisión deliberada de compatibilidad. El sistema de tipos es permisivo aquí, y la seguridad depende del programador.
  • La asignación no explota en runtime en este caso concreto porque logPersonaje solo lee `nombre`, que Heroe también tiene. El peligro real aparece cuando el método del supertipo accede a propiedades que el subtipo puede no tener.
  • El tier siguiente muestra cómo un cambio cosmético en la declaración cierra ese agujero.