Translate

miércoles, 2 de octubre de 2024

Covarianza, Contravarianza e Invarianza en TypeScript


En el contexto de tipos genéricos, la varianza define cómo los subtipos y supertipos de un tipo afectan las relaciones entre otros tipos.

Existen tres tipos principales de varianza:

Covarianza: Un tipo genérico mantiene la relación de subtipos que tiene el tipo con el que trabaja. Es decir, si A es un subtipo de B, entonces Caja<A> será subtipo de Caja<B>.

Contravarianza: Ocurre cuando la relación de subtipos es inversa. Si A es subtipo de B, entonces Caja<B> es subtipo de Caja<A>.

Invarianza: No existe relación entre Caja<A> y Caja<B>, incluso si A es un subtipo de B.

En TypeScript, exiten tipos que son covariantes por defecto. Esto significa que si un tipo genérico tiene un subtipo, esa relación se mantiene con el tipo genérico.

Veamos un ejemplo sencillo para entender esto mejor:


class Animal {

    nombre: string;

    constructor(nombre: string) {

        this.nombre = nombre;

    }

}


class Perro extends Animal {

    ladrar(): void {

        console.log("Guau!");

    }

}


class Gato extends Animal {

    maullar(): void {

        console.log("Miau!");

    }

}


function imprimirAnimales(animales: Animal[]): void {

    animales.forEach(animal => console.log(animal.nombre));

}


const perros: Perro[] = [new Perro("Max"), new Perro("Rex")];

imprimirAnimales(perros); // Esto es válido, ya que Perro es subtipo de Animal


En este ejemplo, Perro[] es subtipo de Animal[], por lo que la función imprimirAnimales puede recibir una lista de perros sin problema. Esto es covarianza.

Contravarianza significa que un tipo más general (supertipo) puede ser pasado donde se espera un tipo más específico (subtipo). En TypeScript, esto es común al trabajar con funciones.


class Carnivoro extends Animal {}

class Herviboro extends Animal {}


function alimentarAnimal(fn: (a: Carnivoro) => void): void {

    const leon = new Carnivoro("León");

    fn(leon);

}


function alimentarCualquierAnimal(a: Animal): void {

    console.log(`Alimentando a un ${a.nombre}`);

}


alimentarAnimal(alimentarCualquierAnimal); // Funciona gracias a la contravarianza

Aquí, la función alimentarCualquierAnimal puede ser utilizada donde se espera una función que trabaje con Carnivoro, ya que Animal es un supertipo de Carnivoro. Esto es contravarianza.

Si un tipo es invariante, no puedes intercambiar subtipos y supertipos, incluso si existe una relación de herencia entre ellos. Este comportamiento es menos común en TypeScript.


Ejemplo de invarianza:


class Caja<T> {

    contenido: T;

    constructor(contenido: T) {

        this.contenido = contenido;

    }

}


const cajaAnimal: Caja<Animal> = new Caja(new Animal("Elefante"));

const cajaPerro: Caja<Perro> = new Caja(new Perro("Max"));


// Esto genera error porque Caja<Perro> no es subtipo de Caja<Animal>

// cajaAnimal = cajaPerro; 


En este caso, Caja<Perro> no es subtipo de Caja<Animal>, aunque Perro sea subtipo de Animal. Esta es la invarianza, donde no hay compatibilidad entre tipos genéricos con diferentes parámetros de tipo.

En TypeScript, no puedes hacer que Caja<Animal> sea compatible con Caja<Perro> directamente debido a la invarianza de los tipos genéricos. Sin embargo, hay formas de aproximar este comportamiento utilizando tipos genéricos más flexibles, como lo son los tipos comodín (similares a los de Java) o utilizando el modificador readonly para hacer la relación covariante.

Se puede hacer que Caja<T> sea covariante para las lecturas si el contenido de la caja solo es accesible de forma de lectura y no de escritura. Esto se logra declarando las propiedades como `readonly`.


class Caja<out T> {

    readonly contenido: T;

    constructor(contenido: T) {

        this.contenido = contenido;

    }

}


const cajaAnimal: Caja<Animal> = new Caja(new Animal("Elefante"));

const cajaPerro: Caja<Perro> = new Caja(new Perro("Max"));

// Como la propiedad es de solo lectura, es covariante

const otraCajaAnimal: Caja<Animal> = cajaPerro; // Funciona


En este caso, al usar readonly, puedes asignar una Caja<Perro> a una Caja<Animal> porque las cajas son covariantes en la lectura. No obstante, ya no podrías modificar el contenido de la caja.

Otra opción es usar la palabra clave extends para indicar que puedes trabajar con cualquier subtipo de Animal.


class Caja<T extends Animal> {

    contenido: T;

    constructor(contenido: T) {

        this.contenido = contenido;

    }

}


function procesarCajaAnimal(caja: Caja<Animal>) {

    console.log(`Animal: ${caja.contenido.nombre}`);

}


const cajaPerro: Caja<Perro> = new Caja(new Perro("Max"));

procesarCajaAnimal(cajaPerro); // Funciona


Aquí estamos diciendo que Caja<T> puede aceptar cualquier tipo que extienda de Animal. Esto permite que una Caja<Perro> sea pasada a una función que espera una Caja<Animal>.