/blog/typescript-generics
Generics em TypeScript: código reutilizável sem perder a verificação de tipos
Entenda o <T> de dentro para fora, com exemplos rodáveis no TypeScript Playground
2026 - 07 - 14 · 17 min de leitura
TypeScript|Tipagem|Programação
também publicado no hashnode

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 nenhum

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

  1. <T> depois do nome da função declara o parâmetro de tipo.
  2. data: T diz que o argumento é do tipo T, seja ele qual for nesta chamada.
  3. O retorno { status: "ok"; data: T } reutiliza o mesmo T, 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.9

Na 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 toFixed
function 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ção

As 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 T sem constraint não impede nada de entrar, igual ao any. 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); // null

Repare 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 p1

Leia 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.9

Cada 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):

  1. Ele vê que toApiResponse é genérica e tem um parâmetro de tipo T em aberto.
  2. Ele olha para o argumento user e lê o tipo dele: User.
  3. Ele define T = User para esta chamada específica.
  4. Ele substitui User em todo lugar onde T aparece na assinatura: data: T vira data: User, e o data: T do retorno também.
  5. Ele verifica a chamada e o valor retornado contra esses tipos, agora concretos.
  6. Ele emite JavaScript puro com toda a informação de tipo apagada. O T existe só em tempo de compilação e não deixa nenhum rastro no .js final.

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

A saída é:

ok Isaac
Isaac
199.9

Deixei esse mesmo código disponível, pronto para você rodar, sem precisar instalar nada:

🔗 Abrir este código no TypeScript Playground

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.

. . . . . .