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:
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.
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:
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.
type ManejadorHeroe = (h: Heroe) => void;
type ManejadorPersonaje = (p: Personaje) => void;¿Cuál es asignable a cuál?
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:
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 (ManejadorPersonaje → ManejadorHeroe), al revés de los arrays.
// 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): voiden lugar derecibir: (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 T—Tsolo aparece en posición de salida (return). El tipo es covariante.in T—Tsolo aparece en posición de entrada (parámetro). El tipo es contravariante.
// `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.
// 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.
Paso 2: Contravarianza
- Asignas registrarPersonaje (ManejadorPersonaje) a una variable de tipo ManejadorHeroe: la dirección contravariante válida.
- Lees el error de la dirección contraria y entiendes por qué falla.
- Verbalizas: los parámetros de función van en la dirección opuesta a los arrays.
Paso 3: Modificadores in / out
- Añades `out T` a la interfaz Caja y confirmas que Caja<Heroe> es asignable a Caja<Personaje>.
- Añades `in T` a la interfaz Receptor y confirmas que Receptor<Personaje> es asignable a Receptor<Heroe>.
- Entiendes que in/out documentan y verifican la varianza: si usas T en la posición equivocada, TypeScript lo rechaza.
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.
// Solución tier mejor: covarianza (arrays) + contravarianza (funciones).
interface Personaje {
nombre: string;
}
interface Heroe extends Personaje {
rol: string;
}
// ─── Covarianza (tier ok) ──────────────────────────────────────────────────
const heroes: Heroe[] = [{ nombre: "Ana", rol: "Apoyo" }];
// Válido: covariante. Heroe[] asignable a Personaje[].
const personajes: Personaje[] = heroes;
// @ts-expect-error — Personaje[] no es asignable a Heroe[]
const heroesDesdePersonajes: Heroe[] = personajes;
// ─── Contravarianza (tier mejor) ──────────────────────────────────────────
type ManejadorHeroe = (h: Heroe) => void;
type ManejadorPersonaje = (p: Personaje) => void;
const registrarPersonaje: ManejadorPersonaje = (p) => {
console.log("Personaje: " + p.nombre);
};
// Válido: contravariante. (p: Personaje) => void es asignable a (h: Heroe) => void.
// Por qué es seguro: quien llame a registrarHeroe le pasará un Heroe; la función
// solo accede a `nombre`, que todo Personaje tiene. No hay riesgo.
const registrarHeroe: ManejadorHeroe = registrarPersonaje;
const soloRol: ManejadorHeroe = (h) => {
// Esta función usa `h.rol`, que solo existe en Heroe, no en Personaje.
// Por eso no puede usarse donde se espera ManejadorPersonaje: si alguien
// le pasara un Personaje plano, accedería a `rol` que no existe.
console.log("Rol: " + h.rol);
};
// @ts-expect-error — ManejadorHeroe no es asignable a ManejadorPersonaje
// (soloRol accede a `rol`, que Personaje no tiene)
const comoManejadorPersonaje: ManejadorPersonaje = soloRol;
console.log("Funciones: la dirección segura es contraria a la de los arrays."); Por qué es mejor que el anterior
- ManejadorPersonaje es asignable a ManejadorHeroe porque quien llame al manejador le pasará un Heroe, que también es un Personaje. La función puede manejarlo sin problemas.
- La dirección contraria falla porque soloRol accede a `rol`, que Personaje no tiene. Si se usara como ManejadorPersonaje, alguien podría llamarla con un Personaje plano y el acceso a `rol` fallaría en silencio o daría undefined.
- La regla: para los arrays la dirección válida es subtipo → supertipo. Para los parámetros de función, al revés: supertipo → subtipo. Esa asimetría es la esencia de la varianza.
// Solución tier excelente: covarianza + contravarianza + modificadores in/out.
interface Personaje {
nombre: string;
}
interface Heroe extends Personaje {
rol: string;
}
// ─── Covarianza (tier ok) ──────────────────────────────────────────────────
const heroes: Heroe[] = [{ nombre: "Ana", rol: "Apoyo" }];
// Válido: Heroe[] asignable a Personaje[].
const personajes: Personaje[] = heroes;
// @ts-expect-error — Personaje[] no es asignable a Heroe[]
const heroesDesdePersonajes: Heroe[] = personajes;
// ─── Contravarianza (tier mejor) ──────────────────────────────────────────
type ManejadorHeroe = (h: Heroe) => void;
type ManejadorPersonaje = (p: Personaje) => void;
const registrarPersonaje: ManejadorPersonaje = (p) => {
console.log("Personaje: " + p.nombre);
};
// Válido: contravariante. ManejadorPersonaje asignable a ManejadorHeroe.
const registrarHeroe: ManejadorHeroe = registrarPersonaje;
const soloRol: ManejadorHeroe = (h) => {
console.log("Rol: " + h.rol);
};
// @ts-expect-error — ManejadorHeroe no es asignable a ManejadorPersonaje
const comoManejadorPersonaje: ManejadorPersonaje = soloRol;
// ─── Modificadores in / out (tier excelente) ───────────────────────────────
// `out T` declara que T solo aparece en posición de SALIDA (return).
// Eso hace que Caja<T> sea covariante: Caja<Heroe> asignable a Caja<Personaje>.
// El modificador es documentación + verificación: TypeScript comprueba que T
// solo se use en posición out y da error si intentas usarla como parámetro.
interface Caja<out T> {
leer(): T;
}
const cajaHeroe: Caja<Heroe> = { leer: () => ({ nombre: "Mercy", rol: "Apoyo" }) };
// Válido: covariante por `out`. Caja<Heroe> asignable a Caja<Personaje>.
const cajaPersonaje: Caja<Personaje> = cajaHeroe;
// `in T` declara que T solo aparece en posición de ENTRADA (parámetro).
// Eso hace que Receptor<T> sea contravariante: Receptor<Personaje> asignable
// a Receptor<Heroe>. La anotación explicit hace que el compilador lo verifique
// en lugar de solo inferirlo.
interface Receptor<in T> {
recibir(valor: T): void;
}
const receptorPersonaje: Receptor<Personaje> = {
recibir: (p) => { console.log(p.nombre); }
};
// Válido: contravariante por `in`. Receptor<Personaje> asignable a Receptor<Heroe>.
// Por qué: quien llame a receptorHeroe.recibir() le pasará un Heroe, que es
// también un Personaje, así que la función puede manejarlo sin problema.
const receptorHeroe: Receptor<Heroe> = receptorPersonaje;
console.log("in/out: varianza explícita anotada y verificada por el compilador."); Por qué es mejor que el anterior
- `out T` en Caja declara que T solo aparece en posición de retorno. Eso hace la interfaz covariante y TypeScript acepta Caja<Heroe> donde se espera Caja<Personaje>. Además, si intentas añadir un parámetro con T en una interfaz `out T`, el compilador lo rechaza: el modificador no solo documenta, verifica.
- `in T` en Receptor declara que T solo aparece como parámetro. Eso hace la interfaz contravariante: Receptor<Personaje> es asignable a Receptor<Heroe>, igual que los tipos de función que viste en el tier anterior.
- En la práctica, TypeScript ya infiere la varianza correcta sin los modificadores. Los usas cuando quieres dejarla fijada por escrito (documentación que el compilador verifica), o en tipos complejos donde la inferencia puede ser ambigua.