Variance em generics

1. Introdução à Variance em Sistemas de Tipos

Variance é um conceito fundamental em sistemas de tipos com generics que define como a relação de subtipagem entre tipos concretos se propaga para tipos genéricos. Em outras palavras, dado que Gato é subtipo de Animal, podemos perguntar: Box<Gato> é subtipo de Box<Animal>? A resposta depende da variance do parâmetro T em Box<T>.

Existem quatro tipos de variance:

  • Covariância: a relação de subtipagem é preservada na mesma direção
  • Contravariância: a relação de subtipagem é invertida
  • Invariância: não há relação de subtipagem entre os tipos genéricos
  • Bivariância: ambos os sentidos são válidos (caso especial do TypeScript)

Compreender variance é essencial para projetar APIs seguras e prever o comportamento do sistema de tipos.

2. Covariância em Generics

Um parâmetro de tipo é covariante quando aparece apenas em posições de saída — ou seja, como tipo de retorno de métodos ou propriedades somente leitura. Nesse caso, se A é subtipo de B, então Producer<A> é subtipo de Producer<B>.

// Exemplo: Promise é covariante em T
type Animal = { nome: string };
type Gato = Animal & { mia(): void };

const promessaGato: Promise<Gato> = Promise.resolve({ nome: 'Mimi', mia: () => {} });
const promessaAnimal: Promise<Animal> = promessaGato; // OK: covariância

// Exemplo: Iterator (apenas leitura)
function processarAnimais(iter: Iterator<Animal>) {
  // ...
}

const gatos: Gato[] = [{ nome: 'Mimi', mia: () => {} }];
const iteradorGatos = gatos[Symbol.iterator]();
processarAnimais(iteradorGatos); // OK: Iterator<Gato> é subtipo de Iterator<Animal>

Cuidado com mutabilidade: Arrays mutáveis no TypeScript são tratados como bivariantes por razões práticas, mas conceitualmente deveriam ser invariantes. Se Array<T> fosse covariante, poderíamos inserir um Cachorro em um Array<Gato> via referência de Array<Animal>:

const gatos: Gato[] = [gato1];
const animais: Animal[] = gatos; // TypeScript permite (bivariância)
animais.push(new Cachorro()); // Perigo! gatos agora contém um Cachorro

Por isso, prefira ReadonlyArray<T> ou readonly T[] para garantir covariância segura:

const gatosReadonly: readonly Gato[] = [gato1];
const animaisReadonly: readonly Animal[] = gatosReadonly; // OK: covariante e seguro

3. Contravariância em Generics

Um parâmetro é contravariante quando aparece apenas em posições de entrada — como parâmetros de funções ou métodos. Nesse caso, a relação de subtipagem é invertida: se A é subtipo de B, então Consumer<B> é subtipo de Consumer<A>.

// Exemplo: função comparadora
type Comparador<T> = (a: T, b: T) => number;

const compararAnimais: Comparador<Animal> = (a, b) => a.nome.localeCompare(b.nome);
const compararGatos: Comparador<Gato> = compararAnimais; // OK: contravariância

// Exemplo: EventHandler
type EventHandler<T> = (evento: T) => void;

const handlerAnimal: EventHandler<Animal> = (e) => console.log(e.nome);
const handlerGato: EventHandler<Gato> = handlerAnimal; // OK

// Demonstração com funções como valores
type FuncaoGato = (param: Gato) => void;
type FuncaoAnimal = (param: Animal) => void;

const fnAnimal: FuncaoAnimal = (animal) => console.log(animal.nome);
const fnGato: FuncaoGato = fnAnimal; // OK: FuncaoAnimal é subtipo de FuncaoGato

A intuição é: quem sabe processar qualquer Animal certamente sabe processar um Gato (que é um tipo mais específico). Por isso, (Animal) => void é subtipo de (Gato) => void.

4. Invariância e o Comportamento Padrão do TypeScript

Por padrão, parâmetros de tipo em TypeScript são invariantes. Isso significa que Box<Gato> não é subtipo nem supertipo de Box<Animal> — eles são tipos distintos.

class Caixa<T> {
  constructor(private conteudo: T) {}

  getConteudo(): T {
    return this.conteudo;
  }

  setConteudo(valor: T): void {
    this.conteudo = valor;
  }
}

const caixaGato: Caixa<Gato> = new Caixa({ nome: 'Mimi', mia: () => {} });
// const caixaAnimal: Caixa<Animal> = caixaGato; // Erro: invariância
// caixaAnimal.setConteudo(new Cachorro()); // Isso seria catastrófico

A invariância é o comportamento mais seguro, pois evita que operações de escrita corrompam a integridade do tipo. Compare com Java, onde arrays são covariantes (e causam ArrayStoreException em runtime), ou C#, onde generics são invariantes por padrão mas permitem declaração explícita de variance.

5. Marcadores de Variance: in e out

TypeScript 4.7+ introduziu marcadores explícitos de variance para type aliases e interfaces, permitindo que o compilador verifique e documente a variance pretendida.

// out: parâmetro covariante (apenas saída)
type Produtor<out T> = {
  produzir(): T;
  readonly valor: T;
};

// in: parâmetro contravariante (apenas entrada)
type Consumidor<in T> = {
  consumir(item: T): void;
};

// Exemplo completo: processador com entrada contravariante e saída covariante
type Processador<in T, out R> = (input: T) => R;

const processar: Processador<Animal, string> = (a) => a.nome;
const processarGato: Processador<Gato, string> = processar; // OK: contravariância em T
const processarString: Processador<Animal, string | number> = processar; // OK: covariância em R

// Interface com marcadores
interface Leitor<out T> {
  ler(): T;
}

interface Escritor<in T> {
  escrever(valor: T): void;
}

// Erro de compilação se violar a variance declarada
interface Invalido<out T> {
  // setar(valor: T): void; // Erro: T em posição de entrada, mas declarado como out
}

6. Variance em Funções e Callbacks

Em tipos de função, a variance segue uma regra clara: parâmetros são contravariantes, retorno é covariante.

// Função de alta ordem
type Mapeador<T, R> = (item: T) => R;

function mapearArray<T, R>(arr: T[], fn: Mapeador<T, R>): R[] {
  return arr.map(fn);
}

// Composição de funções
function compor<A, B, C>(
  f: (a: A) => B,
  g: (b: B) => C
): (a: A) => C {
  return (x) => g(f(x));
}

// Exemplo com callbacks
function agendar<T>(
  callback: (dados: T) => void,
  timeout: number
): void {
  setTimeout(() => callback({} as T), timeout);
}

// Armadilha comum: callback que espera tipo mais específico
function processarNumeros(callback: (n: number) => void): void {
  callback(42);
}

const callbackGenerico: (valor: unknown) => void = (v) => console.log(v);
processarNumeros(callbackGenerico); // Erro: (unknown) => void não é subtipo de (number) => void

7. Casos Avançados e Limitações

Tipos recursivos:

interface TreeNode<out T> {
  valor: T;
  filhos: readonly TreeNode<T>[]; // Covariante por ser readonly
}

// Com contravariância
interface Predicado<in T> {
  testar(item: T): boolean;
  and(outro: Predicado<T>): Predicado<T>; // T em posição de entrada novamente
}

Interação com união e interseção:

// Covariância distribui sobre união
type Covariante<T> = { valor: T };
// Covariante<A | B> = Covariante<A> | Covariante<B>

// Contravariância distribui sobre interseção
type Contravariante<T> = (param: T) => void;
// Contravariante<A & B> = Contravariante<A> & Contravariante<B>

Limitação conhecida: métodos em classes são tratados como bivariantes no TypeScript por compatibilidade. Isso pode levar a situações inseguras:

class Colecao {
  itens: Animal[] = [];
  adicionar(item: Animal): void { this.itens.push(item); }
}

class ColecaoGatos extends Colecao {
  // TypeScript permite override com tipo mais específico
  adicionar(item: Gato): void { super.adicionar(item); }
}

const colecao: Colecao = new ColecaoGatos();
colecao.adicionar(new Cachorro()); // Perigo! Runtime error potencial

Boas práticas:
- Use ReadonlyArray<T> para garantir covariância
- Prefira readonly em propriedades de tipos genéricos
- Utilize marcadores in/out para documentar intenção
- Evite mutabilidade em tipos genéricos sempre que possível

8. Conclusão e Aplicações Práticas

Variance é um conceito sutil mas crucial para escrever código TypeScript seguro e expressivo. Resumindo:

  • Covariância (out): para produtores de dados (retorno de funções, leitura)
  • Contravariância (in): para consumidores de dados (parâmetros de funções, callbacks)
  • Invariância (padrão): para tipos que leem e escrevem o mesmo tipo
  • Bivariância: apenas em métodos de classes (por compatibilidade)

Ao projetar APIs com generics, pergunte-se: seu tipo é um produtor, consumidor ou ambos? Isso determinará a variance correta e evitará surpresas desagradáveis em tempo de compilação.

Para debugging de erros relacionados a variance, verifique:
1. Se o tipo genérico aparece em posições de entrada e saída simultaneamente
2. Se há mutabilidade desnecessária que força invariância
3. Se marcadores in/out estão corretamente aplicados

Referências