Skip to content

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”.

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) => string

A 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:

: Configsatisfies Config<const T extends Config>
Tipo de porta que sobranumber | stringnumber3000
Valida contra Config?simsimsim, pela restrição
Captura o tipo para reuso?nãonão, fica só no valorsim, T é utilizável em outros tipos
Onde vivenuma variávelnuma expressãonuma 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.

À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.

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 deveria

Duas 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.

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