O básico: o parâmetro de tipo
O ponto de partida é a função genérica, porque é onde o parâmetro de tipo aparece na sua forma mais limpa. O exemplo mínimo, tão pequeno que quase não faz nada, é a função identidade: recebe um valor e devolve o mesmo valor. O interesse dela não está no que faz, está em como preserva o tipo.
function identidade<T>(valor: T): T { return valor;}
const n = identidade(42);// ^? numberconst s = identidade("olá");// ^? stringVale dissecar essa assinatura devagar, porque cada peça reaparece em tudo que vem depois. O <T> logo após o nome é a declaração do parâmetro de tipo: é o momento em que você diz “esta função tem um tipo que só será conhecido na chamada, e vou chamá-lo de T”. Os dois T seguintes, em valor: T e no : T do retorno, são usos desse parâmetro: eles dizem “o argumento tem esse tipo, e o retorno tem esse mesmo tipo”. A diferença entre declarar e usar é a mesma de uma função comum, onde function f(x) declara o parâmetro x e o corpo o usa. Confundir os dois é a origem de metade da estranheza inicial com a sintaxe: o <T> na definição não recebe um tipo, ele cria um nome para o tipo que virá.
A inferência preenche o espaço
Section titled “A inferência preenche o espaço”O que torna a função acima agradável é o que não foi preciso escrever. Ninguém digitou identidade<number>(42). O TypeScript olhou o argumento 42, viu um number, e concluiu que T é number naquela chamada. Essa dedução se chama inferência do argumento de tipo, e é a razão de generics serem, na prática, quase invisíveis no ponto de uso.
A inferência funciona olhando para os argumentos e resolvendo, de trás para frente, qual tipo faz a assinatura bater. Com o firstElement que apareceu no panorama, o mecanismo fica evidente:
function primeiro<T>(lista: T[]): T | undefined { return lista[0];}
const usuarios = [{ id: 1 }, { id: 2 }];const u = primeiro(usuarios);// ^? { id: number } | undefinedO argumento é um { id: number }[], o parâmetro é T[], então T só pode ser { id: number }. A partir daí o retorno T | undefined se resolve sozinho, e o autocomplete de u já conhece o campo id. Nada disso exigiu anotação: a informação estava no argumento, e a inferência a propagou até a saída.
Quando passar o tipo à mão
Section titled “Quando passar o tipo à mão”Se a inferência quase sempre acerta, quando vale a pena escrever o argumento de tipo explicitamente, entre <>, na chamada? Em dois casos.
O primeiro é quando não há de onde inferir. Se o parâmetro de tipo não aparece em nenhum argumento, o TypeScript não tem pista alguma e cai no default unknown. Uma fábrica que só produz, sem consumir, é o caso clássico:
function criarLista<T>(): T[] { return [];}
const a = criarLista();// ^? unknown[] (não havia argumento para inferir T)const b = criarLista<number>();// ^? number[] (aqui você disse qual era o T)O segundo é quando a inferência acerta, mas acerta um tipo mais largo (ou mais estreito) do que você queria. Passar o tipo à mão é a forma de sobrepor a dedução automática:
// Sem anotação, T é inferido como o literal exato de cada argumento.const x = identidade("pendente");// ^? "pendente"
// Com anotação, você força o tipo mais largo que pretende usar depois.const status = identidade<string>("pendente");// ^? stringA regra prática que decorre disso: deixe a inferência trabalhar por padrão, e só alcance os <> quando ela não tiver de onde deduzir, ou quando deduzir algo diferente do que você precisa. Anotar por reflexo, em toda chamada, devolve a verbosidade que os generics existiam para eliminar.
Mais de um parâmetro de tipo
Section titled “Mais de um parâmetro de tipo”Uma função pode declarar quantos parâmetros de tipo precisar, separados por vírgula, e cada um é inferido de forma independente. Uma função que emparelha dois valores num par precisa de dois:
function par<A, B>(a: A, b: B): [A, B] { return [a, b];}
const p = par("idade", 30);// ^? [string, number]A foi inferido do primeiro argumento e B do segundo, sem que um interferisse no outro. Essa independência é o comportamento padrão, e há capítulos adiante que mostram como amarrar um parâmetro ao outro de propósito, com restrições. Por ora, o essencial é que múltiplos parâmetros de tipo são apenas múltiplos espaços reservados, preenchidos cada um pela sua posição.
Generics não são só para funções
Section titled “Generics não são só para funções”Até aqui o parâmetro de tipo viveu em funções, mas o mesmo mecanismo se aplica a qualquer coisa que descreva uma estrutura. Uma interface genérica descreve a forma de um objeto que embrulha um valor de tipo variável:
interface Caixa<T> { valor: T; vazia: boolean;}
const caixaDeTexto: Caixa<string> = { valor: "oi", vazia: false };Repare que aqui, ao usar a interface, o <string> é obrigatório: Caixa sozinho não é um tipo completo, é um tipo que espera um argumento, do mesmo jeito que uma função espera um valor. Caixa<string> é o tipo completo. Essa é a diferença entre o lado da definição, onde <T> declara, e o lado do uso, onde <string> preenche.
Um type alias genérico faz o mesmo para qualquer tipo, não só para a forma de um objeto. É aqui que entra o primeiro dos exemplos que atravessam o curso: Result, uma forma de representar uma operação que ou deu certo com um valor, ou falhou com um erro, sem lançar exceção. Ele é uma união genérica de dois casos:
type Result<T, E> = | { ok: true; valor: T } | { ok: false; erro: E };
function dividir(a: number, b: number): Result<number, string> { if (b === 0) return { ok: false, erro: "divisão por zero" }; return { ok: true, valor: a / b };}
const r = dividir(10, 2);if (r.ok) { r.valor.toFixed(2); // aqui dentro o TS sabe que 'valor' existe e é number} else { r.erro.toUpperCase(); // e aqui sabe que 'erro' existe e é string}O Result<T, E> carrega dois parâmetros: T é o tipo do valor em caso de sucesso, E o tipo do erro em caso de falha. Quem usa preenche os dois conforme o caso, e o if (r.ok) faz o TypeScript estreitar a união para o ramo certo. Esse tipo reaparece nos capítulos de restrições, de condicionais e de padrões, ganhando helpers a cada passo; guarde-o.
Por fim, uma classe genérica parametriza os campos e métodos de uma classe pelo tipo que ela guarda. Uma pilha que serve para qualquer tipo de elemento:
class Pilha<T> { private itens: T[] = [];
empilha(item: T): void { this.itens.push(item); }
desempilha(): T | undefined { return this.itens.pop(); }}
const pilha = new Pilha<number>();pilha.empilha(1);const topo = pilha.desempilha();// ^? number | undefinedO <T> na classe se comporta como nas funções: declara um espaço reservado, e todo uso de T dentro da classe (o array itens, o argumento de empilha, o retorno de desempilha) se refere ao mesmo tipo, fixado quando você escreve new Pilha<number>(). A diferença é o alcance: numa função o parâmetro vive só durante a chamada; numa classe ele vive por toda a instância.
Com o parâmetro de tipo, a inferência e as quatro formas onde ele vive (função, interface, alias e classe), você tem a fundação. O próximo degrau ataca a limitação que já deve estar incomodando: dentro de uma função com um T totalmente livre, você quase nada pode fazer com o valor, porque o compilador não sabe nada sobre ele. Restringir o T é o que devolve esse poder.
Palavras-chave
Section titled “Palavras-chave”Conceitos: parâmetro de tipo, argumento de tipo, declaração vs uso, inferência, default unknown, apagamento em runtime
Sintaxe: <T>, múltiplos parâmetros, argumento de tipo explícito
Formas: função genérica, interface genérica, type alias genérico, classe genérica
Exemplos: identidade, primeiro, par, Caixa, Result, Pilha