Guia Completo de Variância em TypeScript: Covariance, Contravariance e Bivariance Já leu

Entendendo Variância: O Problema Fundamental Variância é um conceito que descreve como tipos genéricos se relacionam com seus subtipos. Quando você trabalha com programação orientada a objetos, herança e tipos genéricos em TypeScript, surgem questões cruciais: se é um subtipo de , então é um subtipo de ? A resposta não é tão simples quanto parece, e é aqui que variância entra em cena. O problema emerge quando tentamos atribuir um tipo genérico mais específico a uma variável de um tipo genérico mais amplo. Sem um sistema de variância bem definido, você poderia enfrentar erros em tempo de execução. Por exemplo, se você pudesse adicionar um a um declarado como , violaria a segurança de tipos. TypeScript resolve isso através de covariance, contravariance e bivariance — regras que definem quando uma atribuição genérica é segura. Covariance: Mantendo a Hierarquia de Tipos O Conceito de Covariance Covariance significa que se é um subtipo de , então é um subtipo de .

Entendendo Variância: O Problema Fundamental

Variância é um conceito que descreve como tipos genéricos se relacionam com seus subtipos. Quando você trabalha com programação orientada a objetos, herança e tipos genéricos em TypeScript, surgem questões cruciais: se Cachorro é um subtipo de Animal, então Array<Cachorro> é um subtipo de Array<Animal>? A resposta não é tão simples quanto parece, e é aqui que variância entra em cena.

O problema emerge quando tentamos atribuir um tipo genérico mais específico a uma variável de um tipo genérico mais amplo. Sem um sistema de variância bem definido, você poderia enfrentar erros em tempo de execução. Por exemplo, se você pudesse adicionar um Gato a um Array<Animal> declarado como Array<Cachorro>, violaria a segurança de tipos. TypeScript resolve isso através de covariance, contravariance e bivariance — regras que definem quando uma atribuição genérica é segura.

Covariance: Mantendo a Hierarquia de Tipos

O Conceito de Covariance

Covariance significa que se T é um subtipo de U, então Generic<T> é um subtipo de Generic<U>. Em outras palavras, a relação de herança é preservada no tipo genérico. Isso funciona bem para posições de saída (quando você lê dados) porque é seguro retornar algo mais específico do que o esperado.

class Animal {
  nome: string = "Animal";
  fazerSom() {
    console.log("Som genérico");
  }
}

class Cachorro extends Animal {
  fazerSom() {
    console.log("Au au!");
  }
}

class Gato extends Animal {
  fazerSom() {
    console.log("Miau!");
  }
}

// Covariance em tipos retornados
function obterAnimais(): Cachorro[] {
  return [new Cachorro()];
}

const animais: Animal[] = obterAnimais(); // ✅ Funciona - Covariance
console.log(animais[0].fazerSom());

Neste exemplo, Cachorro[] é atribuído a Animal[]. Isso é seguro porque quando você acessa elementos através da referência Animal[], espera comportamentos de Animal. Como Cachorro é um subtipo e implementa corretamente todos os comportamentos, não há problema.

// Exemplo mais prático: Callbacks que retornam tipos
interface Provedor<T> {
  obter(): T;
}

const provedorCachorro: Provedor<Cachorro> = {
  obter() {
    return new Cachorro();
  }
};

const provedorAnimal: Provedor<Animal> = provedorCachorro; // ✅ Covariance
const animal = provedorAnimal.obter();
animal.fazerSom(); // Funciona corretamente

Limitações e Riscos

Covariance é segura para leitura, mas perigosa para escrita. Se você tentar adicionar elementos a um array declarado com covariance, pode enfrentar problemas:

const animais: Animal[] = [new Animal()];
const cachorros: Cachorro[] = animais as Cachorro[]; // ❌ Casting perigoso

cachorros.push(new Gato()); // TypeScript permite, mas é um erro lógico!
// Agora você tem um Gato em um array declarado como Cachorro[]

Contravariance: Invertendo a Hierarquia

O Conceito de Contravariance

Contravariance é o oposto: se T é um subtipo de U, então Generic<U> é um subtipo de Generic<T>. A relação de herança é invertida no tipo genérico. Isso é seguro em posições de entrada (quando você escreve dados) porque é seguro aceitar algo mais geral do que o esperado.

interface Manipulador<T> {
  processar(item: T): void;
}

const manipuladorAnimal: Manipulador<Animal> = {
  processar(animal: Animal) {
    console.log(`Processando ${animal.nome}`);
    animal.fazerSom();
  }
};

const manipuladorCachorro: Manipulador<Cachorro> = manipuladorAnimal; // ✅ Contravariance

// Agora podemos passar um Cachorro sabendo que será processado
const cachorro = new Cachorro();
manipuladorCachorro.processar(cachorro);

Esse código é seguro porque o manipuladorAnimal aceita qualquer Animal, incluindo um Cachorro. Não há risco de tentar acessar propriedades específicas de Cachorro que não existam em Animal.

// Exemplo prático: Callbacks de evento
type EventHandler<T> = (evento: T) => void;

class EventoClique {
  posicaoX: number = 0;
  posicaoY: number = 0;
}

class EventoMouse extends EventoClique {
  botao: number = 0;
}

const tratarEventoGenerico: EventHandler<EventoClique> = (evento) => {
  console.log(`Evento em ${evento.posicaoX}, ${evento.posicaoY}`);
};

const tratarEventoMouse: EventHandler<EventoMouse> = tratarEventoGenerico; // ✅ Contravariance

const eventoMouse = new EventoMouse();
eventoMouse.botao = 1;
tratarEventoMouse(eventoMouse);

Compreendendo a Segurança

Contravariance é segura porque você está aceitando um tipo mais geral. Quem chama a função envia um Cachorro específico, mas o handler sabe tratar qualquer Animal, então funciona. O problema nunca ocorre porque você não pode chamar métodos específicos de Cachorro dentro de um handler que aceita Animal.

Bivariance: A Flexibilidade Perigosa

O Que é Bivariance

Bivariance significa que um tipo genérico aceita tanto covariance quanto contravariance — não há distinção clara. Se T é um subtipo de U, você pode usar Generic<T> onde Generic<U> é esperado, e vice-versa. Essa flexibilidade é prática, mas compromete a segurança de tipos.

interface Caixa<T> {
  guardar(item: T): void;
  retirar(): T;
}

// TypeScript permite bivariance em propriedades de classe/interface
const caixaAnimal: Caixa<Animal> = {
  guardar(animal: Animal) {
    console.log("Guardado:", animal.nome);
  },
  retirar() {
    return new Animal();
  }
};

const caixaCachorro: Caixa<Cachorro> = caixaAnimal; // ⚠️ Bivariance permitida

caixaCachorro.guardar(new Cachorro()); // ✅ Parece seguro
const resultado = caixaCachorro.retirar(); // ❌ Pode não ser Cachorro!
console.log(resultado instanceof Cachorro); // false - é um Animal genérico

Quando Bivariance Causa Problemas

A bivariance de TypeScript é principalmente uma decisão de design para manter compatibilidade e praticidade. Arrays em TypeScript são bivariant, o que significa que você pode atribuir Cachorro[] a Animal[] e vice-versa, mas com riscos:

function adicionarAnimal(animais: Animal[]): void {
  animais.push(new Gato());
}

const cachorros: Cachorro[] = [new Cachorro()];
adicionarAnimal(cachorros); // ✅ Aceito por bivariance

console.log(cachorros[1] instanceof Gato); // true - Ops! Um Gato em Cachorro[]

TypeScript permite isso sem erro em tempo de compilação porque considera arrays como bidirecionais. Esse é um tradeoff entre segurança e praticidade que você deve conhecer.

// Como TypeScript resolve: permite leitura e escrita com o mesmo tipo
function processar(lista: Cachorro[]): void {
  lista.forEach(c => c.fazerSom());
}

const listaMista: Animal[] = [new Cachorro(), new Gato()];
processar(listaMista as Cachorro[]); // ❌ Requer casting - perigo!

Variância em Funções e Métodos

Posição do Tipo Genérico Importa

Em TypeScript, onde você usa um tipo genérico determina a variância aplicável. Posições de entrada (parâmetros) seguem contravariance, posições de saída (retornos) seguem covariance.

// Covariance em posição de retorno
type Produtor<T> = () => T;

const produtorCachorro: Produtor<Cachorro> = () => new Cachorro();
const produtorAnimal: Produtor<Animal> = produtorCachorro; // ✅ Covariance

const animal = produtorAnimal(); // Retorna Cachorro
animal.fazerSom();
// Contravariance em posição de parâmetro
type Consumidor<T> = (item: T) => void;

const consumidorAnimal: Consumidor<Animal> = (animal) => {
  console.log(animal.nome);
};

const consumidorCachorro: Consumidor<Cachorro> = consumidorAnimal; // ✅ Contravariance
consumidorCachorro(new Cachorro());

Métodos de Callback com Múltiplas Posições

interface Transformador<T, U> {
  transformar(item: T): U;
}

const transformarParaCachorro: Transformador<Animal, Cachorro> = {
  transformar(animal: Animal): Cachorro {
    return new Cachorro(); // Simplificado para exemplo
  }
};

// Entrada: contravariance (Animal é mais geral que Cachorro)
// Saída: covariance (Cachorro é mais específico que Animal)
// Isso é invariant como um todo - deve ser exato ou necessita casting
const transformador: Transformador<Cachorro, Animal> = transformarParaCachorro as any;

Conclusão

Neste artigo, você aprendeu que variância define como tipos genéricos se relacionam com hierarquias de herança. Covariance preserva a hierarquia e é segura para leitura, contravariance a inverte e é segura para escrita, e bivariance oferece flexibilidade com riscos. O ponto crítico é reconhecer que TypeScript permite bivariance em arrays e propriedades por design prático, mas isso requer sua vigilância. Na prática, compreender quando aplicar cada conceito ajuda a escrever código type-safe: use tipos de retorno para aproveitar covariance, parâmetros para contravariance, e evite casts perigosos quando possível.

Referências


Artigos relacionados