Imagine que você escreveu uma classe para guardar usuários em memória. Ela tem uma lista interna, um método para adicionar um usuário e um método para buscar um usuário pelo id. Umas trinta linhas, nada de especial.
Na semana seguinte, chega a demanda de guardar produtos. A lógica que você precisa é exatamente a mesma: uma lista interna, adicionar, buscar pelo id. A única diferença é que agora os itens são produtos, e não usuários.
Aqui você tem duas opções óbvias, e as duas incomodam. Você pode copiar a classe inteira e trocar User por Product em cada lugar, e repetir isso para toda entidade nova que aparecer. Ou pode escrever uma classe só, tipando os itens como any para ela aceitar qualquer coisa, e nesse caso o TypeScript para de verificar o que entra e o que sai dela.
Ou seja: ou o código é seguro mas duplicado, ou é reutilizável mas sem verificação nenhuma. Generics existem exatamente para você não precisar escolher entre as duas coisas. Este artigo mostra como, começando pelas duas opções ruins acima, para você sentir o problema antes de ver a solução.
As duas opções ruins
Vamos aos dois tipos do exemplo:
type User = { id: string; name: string; email: string };
type Product = { id: string; name: string; price: number };Os dois precisam das mesmas operações básicas: adicionar um registro, buscar um pelo id. A primeira opção é escrever uma classe para cada tipo:
// Opção A: uma classe para cada tipo. Totalmente segura,
// mas o corpo inteiro é copiado e colado para cada tipo novo.
class UserRepository {
private items: User[] = [];
add(item: User): void {
this.items.push(item);
}
findById(id: string): User | null {
return this.items.find((item) => item.id === id) ?? null;
}
}
class ProductRepository {
private items: Product[] = [];
add(item: Product): void {
this.items.push(item);
}
findById(id: string): Product | null {
return this.items.find((item) => item.id === id) ?? null;
}
}Funciona e é completamente seguro: o compilador verifica cada uso. Mas repare no custo. As duas classes são idênticas, exceto pelo tipo. Se você encontrar um bug em uma delas, digamos, um findById usando == em vez de ===, precisa lembrar de procurar e corrigir o mesmo bug em todas as outras cópias. E cada entidade nova do sistema significa mais uma classe inteira duplicada.
A segunda opção é escrever uma classe só, tipada com any, para escapar da duplicação:
// Opção B: UMA classe, tipada com any, para evitar a duplicação.
// Reutilizável, mas a segurança de tipos foi embora.
class Repository {
private items: any[] = [];
add(item: any): void {
this.items.push(item);
}
findById(id: string): any {
return this.items.find((item) => item.id === id) ?? null;
}
}
const users = new Repository();
users.add({ id: "u1", name: "Isaac", email: "isaac@example.dev" });
const user = users.findById("u1");
console.log(user.price); // undefined - e compila sem erro nenhumPare um momento nessa última linha. user.price deveria ser um erro: User não tem campo price, só Product tem. Mas como findById retorna any, o TypeScript desligou a verificação e o engano passou em silêncio. O bug que o compilador existia para pegar só vai aparecer como um undefined em tempo de execução, que você precisa notar por conta própria. Ou pior: em produção.
Resumindo o dilema: a opção A é segura mas não reutilizável, a opção B é reutilizável mas não segura. Generics são a forma de ter as duas coisas ao mesmo tempo, e no final do artigo vamos reescrever exatamente esse cenário para provar isso.
A ideia central: um tipo como parâmetro
Você já conhece parâmetros comuns: um espaço reservado para um valor que será passado depois, no momento da chamada. Um parâmetro de tipo é a mesma ideia, um nível acima: um espaço reservado para um tipo que será preenchido depois, no momento do uso.
A sintaxe que declara esse espaço são os colchetes angulares <T>. O T não é uma palavra reservada: é só um nome escolhido, do mesmo jeito que você escolhe o nome de qualquer parâmetro. Poderia se chamar Tipo ou Item sem mudar nada no resultado, mas a convenção da comunidade é usar T (de "Type"), e vale seguir ela para que quem lê o seu código reconheça na hora que aquilo é um parâmetro de tipo.
Vamos à menor função genérica útil possível. Imagine que todo endpoint do seu backend responde com o mesmo envelope: um campo de status mais o dado em si. Em vez de escrever toUserResponse, toProductResponse e assim por diante, uma função genérica embrulha qualquer tipo de dado:
// data: T significa "o dado é do tipo que quem chamou passar".
// O tipo de retorno reaproveita esse mesmo T dentro do envelope.
function toApiResponse<T>(data: T): { status: "ok"; data: T } {
return { status: "ok", data };
}Lendo por partes:
<T>depois do nome da função declara o parâmetro de tipo.data: Tdiz que o argumento é do tipoT, seja ele qual for nesta chamada.- O retorno
{ status: "ok"; data: T }reutiliza o mesmoT, ligando o tipo de saída ao tipo de entrada.
E no uso:
const userResponse = toApiResponse({
id: "u1",
name: "Isaac",
email: "isaac@example.dev",
}); // T inferido a partir do argumento, sem escrever <...>
const productResponse = toApiResponse<Product>({
id: "p1",
name: "Teclado",
price: 199.9,
}); // T informado explicitamente
console.log(userResponse.data.name); // Isaac
console.log(productResponse.data.price); // 199.9Na primeira chamada, o TypeScript inferiu o T sozinho: ele leu o objeto passado como argumento e preencheu o parâmetro de tipo com o tipo daquele objeto. É por isso que, na prática, você raramente escreve os <...> na chamada. Na segunda, o tipo foi passado explicitamente, e mais adiante veremos um caso em que isso é obrigatório.
Agora a parte que costuma surpreender: userResponse.data está completamente tipado, com autocomplete e verificação de campos funcionando, mesmo que toApiResponse nunca mencione User em lugar nenhum da sua definição. O tipo entrou pelo argumento e saiu pelo retorno, carregado inteiramente pelo T. Você pode provar isso tentando acessar um campo que pertence a outra entidade:
userResponse.data.price;
// Erro: Property 'price' does not exist on type '{ id: string; name: string; email: string; }'A função genérica lembrou exatamente qual forma entrou, e por isso consegue te impedir de acessar um campo que pertence a outro tipo.
Onde a segurança realmente mora
Olhe de novo para toApiResponse<T>: o T ali não tem nenhuma restrição, então essa função aceita literalmente qualquer coisa. Um User, um Product, uma string solta, um número. Nada na porta de entrada barra um dado estranho. À primeira vista, parece o mesmo buraco que o any tinha na opção B. Não é, e a diferença merece precisão.
A segurança que um generic oferece nunca foi sobre restringir o que entra. Isso é trabalho de uma constraint, que veremos daqui a pouco. A segurança do generic está no que acontece na saída: o tipo que entrou é exatamente o tipo que sai, e tudo que vem depois é verificado contra ele.
Compare os dois modos de falha lado a lado:
function toApiResponse<T>(data: T): { status: "ok"; data: T } {
return { status: "ok", data };
}
const weird = toApiResponse("hello"); // T inferido como string
weird.data.toFixed(2); // não compila: string não tem toFixedfunction wrapAny(data: any): { status: "ok"; data: any } {
return { status: "ok", data };
}
const wrapped = wrapAny("hello"); // data tipado como any
wrapped.data.toFixed(2); // compila normalmente e quebra em execuçãoAs duas funções aceitam a mesma entrada irrestrita. A diferença aparece no que sai delas. O any esquece o tipo que você passou: o retorno também vira any, e o TypeScript deixa de verificar tudo que você fizer com ele daí em diante. O T lembra: ele guarda o tipo que entrou e devolve exatamente ele no retorno, então todo uso de weird.data continua sendo verificado contra string.
Resumindo: um
Tsem constraint não impede nada de entrar, igual aoany. A diferença é que ele não perde o tipo no caminho, e é isso que mantém o resto do seu código sob verificação.
Quando a inferência não tem de onde inferir
Até aqui o T sempre veio de um argumento único, e o TypeScript conseguiu descobrir sozinho qual tipo era. Mas o T também pode vir de dentro de um array, e nesse caso existe uma situação em que a inferência simplesmente não tem como funcionar. Veja uma função que devolve o primeiro item de uma lista, ou null se ela estiver vazia:
// items: T[] significa "um array de algum tipo T". O retorno T | null
// liga o tipo dos elementos do array ao valor devolvido.
function firstOrNull<T>(items: T[]): T | null {
return items.length > 0 ? items[0] : null;
}
const userList: User[] = [
{ id: "u1", name: "Isaac", email: "isaac@example.dev" },
];
const first = firstOrNull(userList); // T inferido como User
const none = firstOrNull<User>([]); // T explícito, array vazio
console.log(none); // nullRepare na chamada com o array vazio: firstOrNull([]) não tem nenhum elemento de onde ler um tipo, então a inferência não tem material para trabalhar. É para esse caso que os argumentos de tipo explícitos existem: quando não há de onde inferir, você informa o tipo na mão com <User>.
E tem mais um detalhe valioso aqui. Como o retorno é T | null, o compilador te obriga a tratar o caso da lista vazia antes de acessar qualquer campo:
first.name; // Erro: 'first' is possibly 'null'
if (first !== null) {
console.log(first.name); // Isaac - agora sim
}É a mesma exigência que uma consulta real a um banco de dados deveria impor: o registro pode não existir, e o tipo de retorno da função genérica deixa isso explícito.
Constraints: exigindo uma forma mínima
Por padrão, um parâmetro de tipo pode ser qualquer coisa. E se T pode ser qualquer coisa, inclusive um número, o compilador não deixa você ler nenhuma propriedade dele:
function describeById<T>(value: T): string {
return `record ${value.id}`;
// Erro: Property 'id' does not exist on type 'T'
}Do ponto de vista do compilador, a recusa faz todo sentido: nada garante que o T desta chamada tenha um campo id. No momento em que o seu código genérico precisa ler um campo específico, você precisa prometer que esse campo existe. Essa promessa é a constraint.
"Constraint" é uma palavra do inglês que significa restrição, limitação. É esse o papel dela aqui: em vez de deixar o T aceitar qualquer tipo, você restringe o T a tipos que tenham uma certa forma mínima. Na prática, ela é escrita logo depois do parâmetro de tipo:
// A constraint vai logo depois do parâmetro de tipo.
function describeById<T extends { id: string }>(value: T): string {
return `record ${value.id}`; // permitido: a constraint garante o id
}
const user: User = { id: "u1", name: "Isaac", email: "isaac@example.dev" };
const product: Product = { id: "p1", name: "Teclado", price: 199.9 };
console.log(describeById(user)); // record u1
console.log(describeById(product)); // record p1Leia a constraint em voz alta como "T, desde que tenha um id do tipo string". O extends aqui não tem nada a ver com herança de classes: nessa posição, ele significa "é atribuível a esta forma", um jeito formal de dizer "tem pelo menos estes campos". E repare no "pelo menos": a constraint pede um id do tipo string e nada além disso. User tem email, Product tem price, e a constraint não se importa com nenhum dos dois. Os dois tipos passam porque atendem ao mínimo exigido, e é isso que permite que describeById seja chamada com um User ou com um Product sem reclamação nenhuma do compilador.
E a constraint faz dois trabalhos com uma cláusula só. Ela concede acesso: dentro da função, agora você pode ler value.id. E ela filtra quem chama: qualquer coisa sem um id do tipo string é rejeitada antes de entrar.
const noId = { name: "Teclado", price: 199.9 };
describeById(noId);
// Erro: Property 'id' is missing in type '{ name: string; price: number; }'
// but required in type '{ id: string; }'Esse é um erro genuinamente útil de receber: noId parece um Product que alguém esqueceu de anexar um id antes de salvar. A constraint pegou o engano antes de ele virar uma chamada de banco de dados com dado incompleto.
A classe genérica: resolvendo o problema do começo
Uma classe também pode receber um parâmetro de tipo, escrito do mesmo jeito: class Repository<T>. A diferença é o momento em que o tipo é escolhido: em uma função genérica, o T é preenchido a cada chamada; em uma classe genérica, ele é escolhido uma vez, na criação da instância com new Repository<User>(), e todos os métodos daquela instância ficam travados nesse tipo dali em diante.
É aqui que tudo se conecta. O Repository<T> abaixo substitui o UserRepository e o ProductRepository do começo do artigo, sem nenhuma duplicação e sem nenhuma perda de segurança:
// A constraint no nível da classe garante que todo item guardado
// tem um id do tipo string, então findById funciona para
// qualquer entidade que T venha a ser.
class Repository<T extends { id: string }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string): T | null {
// find retorna T | undefined; normalizamos undefined para null.
return this.items.find((item) => item.id === id) ?? null;
}
}
// O tipo é decidido uma vez, na criação, com <...>. Esta ÚNICA classe
// agora serve as duas entidades que antes exigiam classes duplicadas.
const users = new Repository<User>();
users.add({ id: "u1", name: "Isaac", email: "isaac@example.dev" });
const products = new Repository<Product>();
products.add({ id: "p1", name: "Teclado", price: 199.9 });
const user = users.findById("u1");
const product = products.findById("p1");
if (user !== null) console.log(user.name); // Isaac
if (product !== null) console.log(product.price); // 199.9Cada instância é completa e separadamente tipada: users.add(...) só aceita User, products.add(...) só aceita Product, e existe exatamente uma cópia de add e findById para manter para sempre.
Agora, o teste final. Lembra do bug silencioso da opção B, o user.price que compilava e imprimia undefined? Tente o engano equivalente aqui:
if (user !== null) {
user.price;
// Erro: Property 'price' does not exist on type 'User'
}Esse contraste é o artigo inteiro em duas linhas: o mesmo tipo de engano, ler um campo de Product em um User, era silencioso com any e é erro de compilação com Repository<T>. Você ficou com a reutilização do any sem abrir mão da segurança das classes escritas à mão.
E a constraint da classe é verificada no instante em que você nomeia o tipo, antes de qualquer método ser chamado:
const invalid = new Repository<number>();
// Erro: Type 'number' does not satisfy the constraint '{ id: string; }'Números não têm id, então um Repository<number> nem chega a existir.
Como o compilador resolve uma chamada genérica
Para consolidar o mecanismo, vale ver o que o compilador faz, em ordem, em uma chamada como toApiResponse(user):
- Ele vê que
toApiResponseé genérica e tem um parâmetro de tipoTem aberto. - Ele olha para o argumento
usere lê o tipo dele:User. - Ele define
T = Userpara esta chamada específica. - Ele substitui
Userem todo lugar ondeTaparece na assinatura:data: Tviradata: User, e odata: Tdo retorno também. - Ele verifica a chamada e o valor retornado contra esses tipos, agora concretos.
- Ele emite JavaScript puro com toda a informação de tipo apagada. O
Texiste só em tempo de compilação e não deixa nenhum rastro no.jsfinal.
Uma vez preenchido o parâmetro, o compilador trata a função ou a classe como se ela tivesse sido escrita à mão para aquele tipo específico. toApiResponse(user) se comporta exatamente como uma função tipada só para User, e new Repository<Product>() se comporta como uma classe escrita só para Product. Você escreveu uma definição só, mas o compilador enxerga uma versão dedicada e verificada para cada tipo que você usar.
Generics e os utility types
Se você leu o artigo sobre mapped types, uma peça que ficou de fora de lá agora se encaixa. Os utility types nativos do TypeScript, como Partial<T>, Required<T> e Readonly<T>, são a combinação das duas ideias: um mapped type que descreve a transformação, e um generic que deixa o tipo de origem em aberto.
// O Partial<T> nativo é, essencialmente, isto:
type MyPartial<T> = { [K in keyof T]?: T[K] };
type ProductDraft = MyPartial<Product>;
// { id?: string; name?: string; price?: number }O { [K in keyof T]?: T[K] } é o mapped type. O <T> na declaração é o generic, e é ele que permite aplicar a mesma transformação a qualquer tipo do seu sistema em vez de a um tipo fixo. Sem generics, cada utility type teria que ser reescrito para cada tipo do projeto, que é exatamente o problema de duplicação com que este artigo começou, só que no nível dos tipos.
O arquivo completo
Tudo acima se resume nos dois casos abaixo: a função genérica com inferência e a classe genérica com constraint. É um arquivo que você pode rodar com npx tsc e depois node.
/*
Generics: uma definição, um tipo diferente por uso.
*/
type User = { id: string; name: string; email: string };
type Product = { id: string; name: string; price: number };
// ---
// Caso A: função genérica. T é inferido a partir do argumento
// e carrega o tipo da entrada até a saída.
function toApiResponse<T>(data: T): { status: "ok"; data: T } {
return { status: "ok", data };
}
const userResponse = toApiResponse({
id: "u1",
name: "Isaac",
email: "isaac@example.dev",
});
console.log(userResponse.status, userResponse.data.name); // ok Isaac
// ---
// Caso B: classe genérica com constraint. O tipo é escolhido uma vez,
// na criação da instância, e todos os métodos ficam travados nele.
class Repository<T extends { id: string }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string): T | null {
return this.items.find((item) => item.id === id) ?? null;
}
}
const users = new Repository<User>();
users.add({ id: "u1", name: "Isaac", email: "isaac@example.dev" });
const products = new Repository<Product>();
products.add({ id: "p1", name: "Teclado", price: 199.9 });
const user = users.findById("u1");
const product = products.findById("p1");
if (user !== null) console.log(user.name); // Isaac
if (product !== null) console.log(product.price); // 199.9A saída é:
ok Isaac
Isaac
199.9Deixei esse mesmo código disponível, pronto para você rodar, sem precisar instalar nada:
Com o código aberto, experimente algumas variações. Tente user.price dentro do if e veja o erro que o any deixava passar. Tente new Repository<number>() e veja a constraint rejeitando o tipo na hora. Depois remova o extends { id: string } da classe e repare que o próprio findById para de compilar. É mexendo nessas peças que o <T> deixa de parecer sintaxe decorada e passa a ser uma ferramenta que você sabe montar do zero.
Conclusão
Generics resolvem um dilema que todo projeto TypeScript encontra cedo ou tarde: código reutilizável ou código seguro, escolha um. Com um parâmetro de tipo, você não precisa escolher. A lógica é escrita uma vez, o tipo fica em aberto, e cada uso preenche o espaço com um tipo concreto que o compilador verifica por completo, como se a função ou a classe tivesse sido escrita à mão só para ele.
O detalhe que vale levar deste artigo é onde a segurança mora. Um T sem constraint aceita tanta coisa quanto um any, mas o any esquece o tipo assim que ele entra, enquanto o T o leva intacto até o retorno, mantendo tudo que vem depois sob verificação. E quando o seu código genérico precisa contar com algum campo, a constraint T extends { ... } transforma essa necessidade em um contrato: concede o acesso por dentro e filtra as chamadas por fora.
Se os mapped types te ensinaram a derivar tipos de outros tipos, os generics te ensinam a escrever lógica que funciona para todos eles. Juntas, as duas peças explicam praticamente todo utility type que o TypeScript traz pronto, e te dão o vocabulário para escrever os seus próprios quando os nativos não resolverem.