Inferência: como o TypeScript decide os tipos
No primeiro capítulo a inferência parecia mágica benigna: você escrevia primeiro(["a", "b"]) e o T aparecia como string sem esforço. Nos casos simples é assim mesmo. O atrito começa quando o tipo precisa fluir de um lugar para outro dentro de uma mesma chamada, sobretudo através de funções que recebem outras funções. É aqui que quem já domina a sintaxe ainda trava, porque o problema deixa de ser “como escrevo o generic” e passa a ser “por que o compilador inferiu isso, e não aquilo”.
O tipo flui, e a ordem importa
Section titled “O tipo flui, e a ordem importa”Comece pelo caso que funciona, para depois ver onde ele quebra. Uma função mapear que aplica uma transformação a cada item precisa de dois parâmetros de tipo: o do item de entrada e o do resultado:
function mapear<T, U>(lista: T[], transformar: (item: T) => U): U[] { return lista.map(transformar);}
const comprimentos = mapear(["a", "bb", "ccc"], (s) => s.length);// ^? number[]Repare que o parâmetro s do callback não precisou de anotação, e ainda assim o editor sabe que é uma string. Isso não é sorte: é a inferência fluindo em ordem. O primeiro argumento, ["a", "bb", "ccc"], fixa T como string. Fixado T, o compilador empurra esse tipo para dentro do callback, dando a s o tipo string por contexto, e a partir do return s.length conclui que U é number. O tipo entrou por um argumento e escorreu até o outro.
O ponto frágil é quando não há um primeiro argumento para dar a partida. Uma função pipe, que encadeia duas transformações, recebe só funções, e nenhuma delas traz um valor de onde inferir o tipo inicial:
function pipe<A, B, C>(f: (a: A) => B, g: (b: B) => C): (a: A) => C { return (a) => g(f(a));}
// Falha silenciosa: sem anotar 'x', não há de onde inferir A.const ruim = pipe((x) => x * 2, (y) => y.toString());// ^ 'x' é implicitamente 'any' / 'unknown', e x * 2 não checa
// Ancorado: uma anotação mínima em 'x' dá a partida, e o resto flui.const bom = pipe((x: number) => x * 2, (y) => y.toString());// ^? (a: number) => stringA cura é o que se chama de ancorar a inferência: fornecer, no menor ponto possível, o tipo que falta para o resto se resolver. Aqui bastou anotar x: number na primeira função. A partir dela, A é number, B sai do x * 2 como number, y recebe esse number por contexto, e C vem do toString(). Uma anotação destravou quatro inferências. Reconhecer onde colocar essa âncora, no ponto de partida da cadeia, é boa parte da habilidade com generics no código real.
O reduce é o exemplo canônico do mesmo problema com outra roupa. Quando o acumulador tem o mesmo tipo dos itens, tudo se infere; quando ele é uma estrutura nova, o valor inicial não carrega tipo suficiente e você precisa anotá-lo:
// Flui sozinho: o acumulador é number, como os itens, inferido do 0.const soma = [1, 2, 3].reduce((acc, n) => acc + n, 0);// ^? number
// Precisa de âncora: o {} inicial infere como {}, e você não pode indexá-lo.const porId = [{ id: 1 }, { id: 2 }].reduce( (acc, item) => { acc[item.id] = item; return acc; }, {} as Record<number, { id: number }>, // a âncora: diz o que o acumulador é);Sem o as Record<...>, o valor inicial {} seria inferido como o tipo {}, e a linha acc[item.id] = item falharia, porque {} não tem índice. A anotação no valor inicial é a âncora que informa ao reduce qual é, de fato, o tipo do acumulador.
Anotação, satisfies e parâmetro genérico
Section titled “Anotação, satisfies e parâmetro genérico”Três ferramentas parecem fazer a mesma coisa (associar um tipo a um valor) mas diferem no que preservam, e escolher errado entre elas causa perdas de tipo sutis. Vale compará-las sobre o mesmo objeto de configuração:
type Config = Record<string, number | string>;
// 1. Anotação: valida, mas ALARGA cada campo para o tipo anotado.const a: Config = { porta: 3000, host: "local" };a.porta; // number | string (perdeu-se que porta, especificamente, é numérica)// a.porta.toFixed(2) -> erro: 'toFixed' não existe em 'string'
// 2. satisfies: valida contra Config, mas MANTÉM o tipo inferido de cada campo.const b = { porta: 3000, host: "local" } satisfies Config;b.porta; // number (validou o vínculo com Config sem alargar)b.porta.toFixed(2); // ok
// 3. Parâmetro genérico com const: CAPTURA o tipo mais preciso num T reutilizável.function definir<const T extends Config>(c: T): T { return c;}const c = definir({ porta: 3000, host: "local" });c.porta; // 3000 (o literal exato, graças ao const; e o tipo de c é reutilizável)A diferença está no que sobra em cada campo depois, e a tabela a torna concreta com o tipo de porta em cada caso:
: Config | satisfies Config | <const T extends Config> | |
|---|---|---|---|
Tipo de porta que sobra | number | string | number | 3000 |
Valida contra Config? | sim | sim | sim, pela restrição |
| Captura o tipo para reuso? | não | não, fica só no valor | sim, T é utilizável em outros tipos |
| Onde vive | numa variável | numa expressão | numa função ou tipo |
A regra prática segue essa gradação de precisão. Use anotação quando quer justamente um contrato largo, onde porta ser number ou string tanto faz e você prefere a garantia de que o objeto é um Config. Use satisfies quando quer o melhor dos dois num valor local: validar contra Config sem perder que porta é numérica. E use um parâmetro genérico quando precisa do tipo capturado para fazer algo com ele, devolvê-lo, derivar outro tipo dele, ou amarrá-lo a um segundo argumento, e é quando a precisão máxima (com const, até o literal) rende.
Sobrecargas ou tipo condicional
Section titled “Sobrecargas ou tipo condicional”Às vezes o retorno de uma função depende do valor que entra, e um parâmetro genérico simples não expressa essa relação. Há duas saídas, e elas não são intercambiáveis. Considere uma fábrica cujo retorno muda conforme uma string de tipo.
A saída por sobrecargas enumera cada caso como uma assinatura própria:
function criar(forma: "circulo"): { raio: number };function criar(forma: "quadrado"): { lado: number };function criar(forma: string): object { return forma === "circulo" ? { raio: 0 } : { lado: 0 };}
const c = criar("circulo");// ^? { raio: number }A saída por tipo condicional usa uma assinatura só, computando o retorno a partir do argumento:
type FormaDe<T> = T extends "circulo" ? { raio: number } : { lado: number };
function criar2<T extends "circulo" | "quadrado">(forma: T): FormaDe<T> { return (forma === "circulo" ? { raio: 0 } : { lado: 0 }) as FormaDe<T>;}
const q = criar2("quadrado");// ^? { lado: number }O trade-off é claro. Sobrecargas são explícitas e ótimas para um punhado fixo de casos: quem lê vê cada assinatura listada, e não precisa entender tipos condicionais. Mas elas não escalam nem se compõem: dez casos são dez assinaturas, e não há como um chamador passar um tipo variável e obter o retorno correspondente. O tipo condicional escala e compõe (o FormaDe<T> funciona para qualquer T, inclusive um vindo de outro generic), ao custo de exigir um as na implementação (o corpo da função não consegue provar sozinho que devolve o ramo certo) e de ser mais denso de ler. Poucos casos fixos e legibilidade em primeiro lugar pedem sobrecarga; relação que precisa fluir através de outros generics pede condicional.
NoInfer: desligar a inferência num ponto
Section titled “NoInfer: desligar a inferência num ponto”Um parâmetro de tipo é inferido de todos os argumentos em que aparece, e às vezes isso é demais. O caso clássico é uma função que recebe um conjunto de valores válidos e um valor inicial que deveria pertencer a esse conjunto:
function criarMaquina<T extends string>(estados: T[], inicial: T): T { return inicial;}
// O bug: 'inicial' também infere T, então T vira a união dos três,// e "pausado" é aceito, apesar de não estar entre os estados.criarMaquina(["ligado", "desligado"], "pausado"); // compila, mas não deveriaDuas coisas conspiram aqui, e a primeira é uma sutileza de inferência que vale isolar. A restrição T extends string não está ali por capricho: é ela que faz T ser inferido como a união de literais "ligado" | "desligado" em vez de alargar para string. Um parâmetro de tipo restrito a um primitivo como string infere literais; sem a restrição, ["ligado", "desligado"] daria T igual a string, e aí “pausado” (que também é uma string) passaria trivialmente, sem graça nenhuma. A restrição é o que torna a checagem possível.
Fixado isso, o bug é que T é inferido tanto de estados quanto de inicial. Como "pausado" participa da inferência, ele simplesmente se junta à união de T, e a checagem que deveria pegá-lo nunca acontece. O NoInfer, do TypeScript 5.4, marca um ponto como “não use isto para inferir”: T passa a vir só dos outros argumentos, e o marcado é apenas verificado contra o resultado:
function criarMaquina2<T extends string>(estados: T[], inicial: NoInfer<T>): T { return inicial;}
criarMaquina2(["ligado", "desligado"], "pausado");// erro: "pausado" não é atribuível a "ligado" | "desligado"Agora T é inferido apenas de estados, fixando a união "ligado" | "desligado", e inicial é conferido contra ela. O NoInfer é a ferramenta precisa para “este argumento deve caber no tipo, não definir o tipo”, e resolve uma classe inteira de bugs em que um argumento secundário alargava o parâmetro sem que ninguém percebesse.
Dominada a inferência, resta o teto: os pontos onde o sistema de tipos tem limites reais, e os padrões que juntam tudo o que o curso construiu. É o último capítulo.
Palavras-chave
Section titled “Palavras-chave”Conceitos: fluxo de inferência, tipagem contextual, ancorar a inferência, falha silenciosa para unknown
Ferramentas: anotação de tipo, satisfies, parâmetro genérico, sobrecarga, tipo condicional, NoInfer
Exemplos: mapear, pipe, reduce, definir/Config, criar com sobrecarga, FormaDe, criarMaquina