learning-front

Extra · Type-level TypeScript (opcional)

Varianza: covarianza y contravarianza

Por qué un Array<Heroe> puede usarse donde se espera Array<Personaje> pero no al revés, y por qué con las funciones la dirección segura es la opuesta: covarianza, contravarianza y los modificadores in/out para anotarla de forma explícita.

En los capítulos anteriores aprendiste a construir tipos genéricos. Este capítulo responde a una pregunta que surge en cuanto los combinas con herencia: ¿si Heroe extiende Personaje, un Array<Heroe> puede usarse donde se espera un Array<Personaje>? La respuesta es sí, pero solo en esa dirección. Y con las funciones la dirección segura es la contraria. Eso es la varianza.

La jerarquía: Heroe extiende Personaje#

Todo el capítulo trabaja con esta jerarquía:

typescript
interface Personaje {
  nombre: string;
}

interface Heroe extends Personaje {
  // Heroe tiene todo lo de Personaje y además rol.
  rol: string;
}

Heroe es un subtipo de Personaje: todo Heroe es también un Personaje, pero no al revés. Un Personaje plano no garantiza tener rol.

Covarianza: el contenedor sigue la dirección del tipo#

Array<T> es covariante en T. Eso significa que si Heroe extiende Personaje, entonces Array<Heroe> también extiende Array<Personaje>. La relación de herencia se propaga al contenedor.

typescript
const heroes: Heroe[] = [{ nombre: "Ana", rol: "Apoyo" }];

// Válido: Heroe[] asignable a Personaje[].
// Por qué es seguro: al leer el array solo accedemos a Personaje,
// y todo Heroe ya tiene nombre. No puede faltar nada.
const personajes: Personaje[] = heroes;

La dirección contraria falla:

typescript
const personajes: Personaje[] = [{ nombre: "Sombra" }];

// Error: Personaje[] no es asignable a Heroe[].
// Por qué falla: si alguien leyera heroesInvalidos[0].rol,
// ese elemento podría ser un Personaje sin rol — acceso inseguro.
const heroesInvalidos: Heroe[] = personajes;

La regla: la asignación covariante va de subtipo a supertipo (Heroe[]Personaje[]). Al revés no.

Contravarianza: los parámetros de función van al revés#

Aquí viene la parte que parece contraintuitiva. Con los parámetros de función, la dirección segura es la opuesta a la de los arrays.

typescript
type ManejadorHeroe     = (h: Heroe) => void;
type ManejadorPersonaje = (p: Personaje) => void;

¿Cuál es asignable a cuál?

typescript
const registrarPersonaje: ManejadorPersonaje = (p) => {
  // Solo accede a nombre, que todo Personaje tiene.
  console.log("Nombre: " + p.nombre);
};

// Válido: ManejadorPersonaje asignable a ManejadorHeroe.
const registrarHeroe: ManejadorHeroe = registrarPersonaje;

¿Por qué es seguro? Quien llame a registrarHeroe le pasará un Heroe. Un Heroe también es un Personaje, así que registrarPersonaje puede manejarlo sin problema: solo accede a nombre, que Heroe tiene.

La dirección contraria no es segura:

typescript
const soloRol: ManejadorHeroe = (h) => {
  // Accede a rol, que Personaje NO tiene.
  console.log("Rol: " + h.rol);
};

// Error: ManejadorHeroe no es asignable a ManejadorPersonaje.
// Por qué falla: si alguien llamara soloRolComoPersonaje con un Personaje
// plano, accedería a `rol` que no existe — error en silencio o undefined.
const soloRolComoPersonaje: ManejadorPersonaje = soloRol;

La regla: los parámetros de función son contravariantes — la asignación va de supertipo a subtipo (ManejadorPersonajeManejadorHeroe), al revés de los arrays.

typescript
// Resumen de las dos direcciones:
// Covarianza (arrays, return):     subtipo  → supertipo  ✓
//                                  supertipo → subtipo   ✗
// Contravarianza (parámetros):     supertipo → subtipo   ✓
//                                  subtipo  → supertipo  ✗

¿Y qué? La consecuencia real#

La contravarianza no es un tecnicismo: es lo que permite que un manejador genérico reemplace a uno específico sin romper nada. En una base de código real, aparece cuando conectas callbacks, event handlers o inyectas funciones como dependencias.

Si registras un manejador que acepta Evento (supertipo) donde el sistema espera un manejador de EventoDeClick (subtipo), TypeScript te deja. Si lo intentas al revés —un manejador que asume propiedades específicas de EventoDeClick donde puede llegar cualquier Evento— TypeScript te para. No como capricho: porque en runtime accederías a propiedades que puede que no existan.

Nota sobre métodos: con strict activado, TypeScript aplica contravarianza a los tipos de función (como los que ves aquí). Los métodos de clase o interface (recibir(v: T): void en lugar de recibir: (v: T) => void) tienen comportamiento bivariante por compatibilidad histórica. Lo verás en detalle en el capítulo siguiente, “strictFunctionTypes y bivarianza”.

Modificadores in y out: varianza explícita (TS 4.7)#

TypeScript infiere la varianza automáticamente. Pero desde la versión 4.7 puedes anotarla de forma explícita con los modificadores in y out en el parámetro de tipo:

  • out TT solo aparece en posición de salida (return). El tipo es covariante.
  • in TT solo aparece en posición de entrada (parámetro). El tipo es contravariante.
typescript
// `out T`: covariante — Caja<Heroe> asignable a Caja<Personaje>
interface Caja<out T> {
  leer(): T;
}

// `in T`: contravariante — Receptor<Personaje> asignable a Receptor<Heroe>
interface Receptor<in T> {
  recibir(valor: T): void;
}

El modificador hace dos cosas a la vez: documenta la intención y la verifica. Si añades out T pero usas T como parámetro en algún método, TypeScript da error porque eso violaría la covarianza declarada. Es la diferencia entre un comentario que puede quedar desactualizado y una restricción que el compilador mantiene.

typescript
// Si añadieras esto a Caja<out T>, TypeScript lo rechaza:
// escribir(valor: T): void  — T en posición de entrada viola `out`

¿Cuándo usarlos? Cuando quieres dejar la varianza fijada por contrato, o cuando trabajas con tipos complejos donde la inferencia puede ser ambigua. En código cotidiano TypeScript lo infiere bien; en librerías públicas o tipos muy genéricos, in/out es documentación ejecutable.

Comprueba lo que sabes#

Pregunta 1 de 4

Tienes `interface Heroe extends Personaje`. ¿Cuál de estas asignaciones es válida?

Tu turno#

Completa las asignaciones válidas del Team Builder (sustituye null! por el valor indicado) y lee los errores de las inválidas para entender en qué dirección es segura cada asignación. En el tier excelente, anota la varianza con in/out y confirma que el compilador acepta las asignaciones esperadas.

Ejercicio · en esta página

Covarianza, contravarianza y modificadores in/out en el Team Builder

Completa las asignaciones válidas (sustituye null! por el valor indicado) y lee los errores de las inválidas para entender en qué dirección es segura cada asignación. En el tier excelente, anota la varianza con in/out.

Paso 1: Covarianza

  • Asignas heroes (Heroe[]) a una variable de tipo Personaje[]: la dirección covariante válida.
  • Lees el error de la dirección contraria y entiendes por qué falla.
  • Verbalizas: Array<T> es covariante — la asignación va de subtipo a supertipo.
Ver soluciones
// Solución tier ok: covarianza en arrays.

interface Personaje {
  nombre: string;
}

interface Heroe extends Personaje {
  rol: string;
}

// Un array de Heroe es asignable a un array de Personaje porque Heroe
// extiende Personaje. TypeScript lo acepta: es covarianza en acción.
const heroes: Heroe[] = [{ nombre: "Ana", rol: "Apoyo" }];

// Asignación válida — covariante:
// Heroe[] asignable a Personaje[] porque todo Heroe ya es un Personaje.
const personajes: Personaje[] = heroes;

// La dirección contraria falla: un array de Personaje no garantiza que
// cada elemento tenga `rol`, así que no puede tratarse como Heroe[].
// @ts-expect-error — Personaje[] no es asignable a Heroe[]
const heroesDesdePersonajes: Heroe[] = personajes;

console.log("Covarianza: Heroe[] es asignable a Personaje[]. La dirección contraria no.");

Por qué este nivel

  • La asignación válida (Heroe[] → Personaje[]) compila porque todo Heroe ya es un Personaje: no hay riesgo de que falte ninguna propiedad al leer.
  • La dirección contraria falla porque Personaje[] no garantiza que cada elemento tenga `rol`. TypeScript lo rechaza para protegerte de un acceso a una propiedad inexistente.
  • El tier siguiente muestra que con las funciones la dirección segura es exactamente la contraria, lo cual resulta sorprendente al principio.