Variância, limites e padrões do mundo real
Os capítulos anteriores construíram uma escada de ferramentas; este sobe ao último degrau, onde o sistema de tipos revela sua direção interna (a variância), seus limites (o que ele não consegue expressar), e onde tudo o que você aprendeu se junta em padrões que valem a pena reconhecer. É um capítulo de fronteira: parte do conteúdo aqui você usará todo dia, parte você talvez nunca escreva à mão, mas entender que existe evita a sensação de “não terminei de aprender generics” quando, na verdade, você chegou à borda da linguagem.
Variância: a direção da compatibilidade
Section titled “Variância: a direção da compatibilidade”Comece com uma pergunta que parece trivial e não é. Se Cachorro é um subtipo de Animal, o que acontece com os tipos genéricos construídos sobre eles? Um Array<Cachorro> pode ser usado onde se espera um Array<Animal>?
class Animal { nome = "";}class Cachorro extends Animal { latir() {}}
// Covariância: a relação de subtipo se propaga na mesma direção.const cachorros: Cachorro[] = [new Cachorro()];const animais: Animal[] = cachorros; // ok: ler um Animal de uma lista de Cachorros é seguroIsso se chama covariância: Array<T> preserva a direção do subtipo, então Array<Cachorro> é atribuível a Array<Animal>. Faz sentido para leitura: todo cachorro que você tirar da lista é, de fato, um animal. (TypeScript aceita isso para arrays mesmo sendo tecnicamente inseguro na escrita, um pragmatismo deliberado que vale conhecer mas não perseguir aqui.)
Agora inverta o cenário: em vez de uma lista de T, uma função que recebe T. A direção da compatibilidade se inverte junto:
type Manipulador<T> = (item: T) => void;
const trataAnimal: Manipulador<Animal> = (a) => console.log(a.nome);const trataCachorro: Manipulador<Cachorro> = (c) => c.latir();
// Contravariância: uma função que trata qualquer Animal serve// onde se pediu uma que trata Cachorro (todo Cachorro é Animal).const usaComoCachorro: Manipulador<Cachorro> = trataAnimal; // ok
// O contrário é inseguro e o compilador recusa:// uma função que só sabe tratar Cachorro receberia um Animal qualquer.const usaComoAnimal: Manipulador<Animal> = trataCachorro; // erroIsso é contravariância: em posição de entrada, a direção do subtipo se inverte. A intuição vale mais que o nome: quem produz T (leitura, saída) é covariante, e um subtipo mais específico serve; quem consome T (escrita, entrada) é contravariante, e é o supertipo mais geral que serve. A maioria dos tipos genéricos mistura as duas coisas, e o compilador calcula a variância resultante sozinho.
Desde o TypeScript 4.7, você pode anotar a variância pretendida, com out para posições de saída e in para as de entrada. As anotações não mudam o comportamento; elas documentam a intenção e fazem o compilador conferir se o uso bate:
// T só aparece na saída: covariante, anotado com out.interface Produtor<out T> { produzir(): T;}
// T só aparece na entrada: contravariante, anotado com in.interface Consumidor<in T> { consumir(valor: T): void;}Se você anotar out T e depois usar T numa posição de entrada, o compilador acusa a incoerência. Em tipos pequenos as anotações são dispensáveis, o compilador infere tudo; em tipos genéricos grandes e recursivos elas ganham valor duplo, como documentação verificada e como atalho de desempenho para o checador.
O teto: tipos de ordem superior que não existem
Section titled “O teto: tipos de ordem superior que não existem”Aqui está um limite que vale conhecer justamente para parar de bater a cabeça nele. Generics abstraem sobre tipos: um T que vira number, string, Cachorro. O que eles não conseguem abstrair é sobre construtores de tipo, coisas como Array, Promise ou Result, que são “tipos que esperam um tipo para virar um tipo”. A capacidade de escrever um genérico parametrizado por um construtor desses chama-se tipos de ordem superior (higher-kinded types), e o TypeScript não a tem.
O sintoma é este. Suponha que você queira descrever, de uma vez, tudo que é “mapeável”, Array, Promise, Result, qualquer contêiner com um map. A definição natural não compila:
// O que gostaríamos de escrever:// interface Funtor<F> {// map<A, B>(fa: F<A>, f: (a: A) => B): F<B>;// }// ^ erro: o tipo 'F' não é genéricoO problema é o F<A>: você está tentando aplicar F a um argumento de tipo, mas F é um parâmetro comum, não um construtor que aceite argumentos. Em linguagens com tipos de ordem superior (Haskell, Scala) isso é rotina; em TypeScript, é uma parede. Existe um contorno conhecido, a defuncionalização, que codifica construtores como chaves de uma interface de lookup e simula a aplicação F<A> por acesso indexado, mas é uma técnica pesada, mais vista em bibliotecas de programação funcional (como a fp-ts) do que em código de aplicação. Para o dia a dia, o que importa é reconhecer a parede quando bater nela: se você se pegar querendo um genérico “sobre o Array, não sobre o que está dentro dele”, é isso, e a resposta honesta costuma ser reformular o problema em vez de forçar o encoding.
O outro limite: a profundidade da recursão
Section titled “O outro limite: a profundidade da recursão”Os tipos recursivos do capítulo de condicionais têm um teto concreto: o compilador limita quão fundo uma instanciação de tipo pode ir antes de desistir, para não travar em recursões que talvez nunca terminem. Um tipo que gera todos os “caminhos” de um objeto aninhado, útil para tipar acessos como "usuario.endereco.cidade", é o exemplo típico onde esse limite aparece:
type Caminhos<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${Caminhos<T[K]>}` : `${K}`; }[keyof T & string] : never;
type C = Caminhos<{ usuario: { nome: string; endereco: { cidade: string } } }>;// ^? "usuario" | "usuario.nome" | "usuario.endereco" | "usuario.endereco.cidade"Sobre objetos de profundidade razoável, funciona lindamente. O problema surge com tipos recursivos, como uma árvore cujo nó contém filhos do mesmo tipo: Caminhos desce sem parar e, em algum ponto, o compilador aborta com o erro “type instantiation is excessively deep and possibly infinite”. A saída é podar a recursão de propósito, carregando um contador de profundidade que decrementa a cada nível e para ao chegar a zero. O contador costuma ser uma tupla cujo comprimento serve de número, e o passo recursivo usa a cauda dela (o infer Resto do capítulo 4) para “contar para baixo”. É uma técnica de nicho, mas saber que o limite existe, e que ele se contorna com um limitador explícito, evita horas de confusão diante de um erro que parece dizer que seu tipo está errado quando ele só está fundo demais.
Os padrões que costuram tudo
Section titled “Os padrões que costuram tudo”O curso inteiro converge nos padrões abaixo, e cada um combina várias das peças aprendidas. Não são exóticos: são a forma como bibliotecas reais usam generics para entregar APIs que parecem adivinhar os tipos.
O builder que acumula tipo encadeia chamadas e vai construindo o tipo do resultado junto com o valor. Cada set devolve um builder cujo parâmetro de tipo é o anterior interseccionado com o novo campo, então ao final o objeto tem exatamente os campos que foram setados, cada um com seu tipo:
class Builder<T extends object = {}> { private dados = {} as T;
set<K extends string, V>(chave: K, valor: V): Builder<T & Record<K, V>> { (this.dados as Record<string, unknown>)[chave] = valor; return this as unknown as Builder<T & Record<K, V>>; }
build(): T { return this.dados; }}
const obj = new Builder().set("nome", "Ana").set("idade", 30).build();// ^? { nome: string } & { idade: number }obj.nome; // stringobj.idade; // numberRepare como o tipo cresce a cada .set: começa em {}, vira {} & Record<"nome", string>, depois interseccionado com Record<"idade", number>. A restrição K extends string e o Record<K, V> do capítulo 7 fazem todo o trabalho; o resultado é um builder onde acessar obj.nome é seguro e acessar um campo nunca setado é erro.
O event emitter tipado é talvez o padrão que melhor mostra o valor de generics em API. Um mapa de eventos associa cada nome ao formato do seu payload, e os métodos on e emit usam keyof e acesso indexado para garantir que o handler de cada evento receba o payload certo, e que emitir um evento inexistente ou com payload errado não compile:
type MapaDeEventos = { login: { usuario: string }; logout: { motivo: string };};
class Emissor<M> { private ouvintes: { [K in keyof M]?: Array<(payload: M[K]) => void> } = {};
on<K extends keyof M>(evento: K, fn: (payload: M[K]) => void): void { (this.ouvintes[evento] ??= []).push(fn); }
emit<K extends keyof M>(evento: K, payload: M[K]): void { this.ouvintes[evento]?.forEach((fn) => fn(payload)); }}
const emissor = new Emissor<MapaDeEventos>();
emissor.on("login", (p) => p.usuario.toUpperCase()); // p é { usuario: string }emissor.emit("login", { usuario: "ana" }); // ok
emissor.emit("login", { motivo: "x" }); // erro: payload não bate com o eventoemissor.on("inexistente", () => {}); // erro: "inexistente" não é um eventoO campo ouvintes é um tipo mapeado sobre keyof M; on e emit são genéricos em K extends keyof M, e o payload M[K] amarra o formato ao evento específico. Cada peça veio de um capítulo diferente, e juntas produzem um emissor onde o compilador é o guarda de que nenhum evento é ouvido ou emitido errado.
Por fim, o Result ganha seus verbos, fechando o fio condutor que começou no primeiro capítulo. Com map e flatMap, você encadeia operações que podem falhar sem nunca escrever um if (r.ok) no meio da lógica de negócio, e sem lançar exceções:
type Result<T, E> = { ok: true; valor: T } | { ok: false; erro: E };
// map: transforma o valor de sucesso, deixa o erro passar intacto.function map<T, U, E>(r: Result<T, E>, f: (v: T) => U): Result<U, E> { return r.ok ? { ok: true, valor: f(r.valor) } : r;}
// flatMap: encadeia outra operação que também pode falhar.function flatMap<T, U, E>(r: Result<T, E>, f: (v: T) => Result<U, E>): Result<U, E> { return r.ok ? f(r.valor) : r;}
const parseNum = (s: string): Result<number, string> => isNaN(Number(s)) ? { ok: false, erro: "não é número" } : { ok: true, valor: Number(s) };
const resultado = flatMap(parseNum("21"), (n) => map({ ok: true, valor: n } as Result<number, string>, (x) => x * 2));// ^? Result<number, string>Os três genéricos de map, T, U e E, dizem tudo: entra um Result sobre T, sai um sobre U, e o E atravessa sem ser tocado. É a mesma ideia do primeiro do primeiro capítulo, preservar a relação entre entrada e saída no tipo, levada a uma estrutura que modela sucesso e falha.
Chegando aqui, você percorreu a escada inteira: do parâmetro de tipo que só carrega um valor até os tipos que decidem, transformam, extraem e se recombinam, e viu onde a linguagem para. A recompensa não é decorar cada recurso, é ler uma assinatura genérica densa e enxergar, por trás dos símbolos, a relação entre tipos que ela está tentando descrever. Esse é o ponto em que generics deixam de ser um obstáculo e viram uma ferramenta de projeto.
Palavras-chave
Section titled “Palavras-chave”Variância: covariância, contravariância, in, out, posição de entrada, posição de saída
Limites: tipos de ordem superior, higher-kinded types, defuncionalização, limite de profundidade de recursão
Padrões: builder que acumula tipo, this polimórfico, event emitter tipado, Result com map e flatMap
Regra: um parâmetro de tipo deve aparecer ao menos duas vezes