Como Usar Generics em TypeScript: Funções, Classes e Interfaces Parametrizadas em Produção Já leu

O que são Generics em TypeScript? Generics são um mecanismo poderoso que permite criar componentes reutilizáveis capazes de trabalhar com múltiplos tipos de dados, mantendo segurança de tipo. Em essência, você define o "molde" de uma função, classe ou interface, deixando o tipo específico ser determinado no momento do uso. Isso elimina a necessidade de duplicar código para diferentes tipos e evita o uso excessivo de , que enfraquece a verificação de tipos do TypeScript. Quando você trabalha sem generics, acaba forçando conversões de tipo ou criando múltiplas versões da mesma lógica. Com generics, você escreve uma solução elegante e type-safe que se adapta a qualquer tipo que o desenvolvedor passar. Pense em generics como "parâmetros para tipos", assim como funções recebem parâmetros para dados. Generics em Funções Sintaxe Básica e Parâmetros de Tipo Uma função genérica utiliza a notação para declarar um parâmetro de tipo. A letra é apenas uma convenção (de "Type"), mas você pode usar qualquer nome.

O que são Generics em TypeScript?

Generics são um mecanismo poderoso que permite criar componentes reutilizáveis capazes de trabalhar com múltiplos tipos de dados, mantendo segurança de tipo. Em essência, você define o "molde" de uma função, classe ou interface, deixando o tipo específico ser determinado no momento do uso. Isso elimina a necessidade de duplicar código para diferentes tipos e evita o uso excessivo de any, que enfraquece a verificação de tipos do TypeScript.

Quando você trabalha sem generics, acaba forçando conversões de tipo ou criando múltiplas versões da mesma lógica. Com generics, você escreve uma solução elegante e type-safe que se adapta a qualquer tipo que o desenvolvedor passar. Pense em generics como "parâmetros para tipos", assim como funções recebem parâmetros para dados.

Generics em Funções

Sintaxe Básica e Parâmetros de Tipo

Uma função genérica utiliza a notação <T> para declarar um parâmetro de tipo. A letra T é apenas uma convenção (de "Type"), mas você pode usar qualquer nome. O tipo T atua como um "espaço em branco" que será preenchido quando a função for chamada.

function identidade<T>(valor: T): T {
  return valor;
}

// TypeScript infere que T é number
const numero = identidade(42);

// Você também pode declarar explicitamente
const texto = identidade<string>("Olá");

// Funciona com objetos também
const usuario = identidade({ nome: "Ana", idade: 28 });

No exemplo acima, identidade retorna exatamente o que recebe, preservando o tipo original. Quando você chama identidade(42), TypeScript automaticamente define T como number. Quando chama identidade<string>("Olá"), você declara explicitamente que T é string.

Múltiplos Parâmetros de Tipo

Funções podem declarar vários parâmetros de tipo, cada um independente. Isso é útil quando você precisa trabalhar com dois ou mais tipos diferentes na mesma função.

function combinar<T, U>(primeiro: T, segundo: U): [T, U] {
  return [primeiro, segundo];
}

const resultado = combinar(10, "teste");
// resultado tem tipo [number, string]

const misturado = combinar<string, boolean>("sim", true);
// Você pode ser explícito também

Neste caso, T e U são dois parâmetros de tipo independentes. A função aceita um valor do tipo T, outro do tipo U, e retorna uma tupla contendo ambos. TypeScript garante que o tipo retornado esteja correto em tempo de compilação.

Restrições de Tipo (Constraints)

Às vezes, você quer limitar quais tipos podem ser usados para um parâmetro genérico. Use a palavra-chave extends para isso.

interface ComComprimento {
  comprimento: number;
}

function obterComprimento<T extends ComComprimento>(obj: T): number {
  return obj.comprimento;
}

// Funciona com string (tem .length)
obterComprimento("teste"); // 5

// Funciona com arrays
obterComprimento([1, 2, 3]); // 3

// Tipo com a propriedade
obterComprimento({ comprimento: 10 }); // 10

// ERRO: número não tem comprimento
// obterComprimento(42); ❌

A restrição extends ComComprimento garante que apenas tipos que possuem a propriedade comprimento podem ser usados. Isso oferece segurança: você pode acessar obj.comprimento sem verificações extras.

Generics em Classes

Estrutura de Classe Genérica

Classes podem ser parametrizadas por tipo, permitindo que uma única classe funcione com diferentes tipos de dados. Isso é particularmente útil para estruturas de dados como listas, caches e filas.

class Armazenador<T> {
  private items: T[] = [];

  adicionar(item: T): void {
    this.items.push(item);
  }

  obter(indice: number): T | undefined {
    return this.items[indice];
  }

  listar(): T[] {
    return [...this.items];
  }
}

const armazenadorNumeros = new Armazenador<number>();
armazenadorNumeros.adicionar(10);
armazenadorNumeros.adicionar(20);
console.log(armazenadorNumeros.listar()); // [10, 20]

const armazenadorTextos = new Armazenador<string>();
armazenadorTextos.adicionar("João");
armazenadorTextos.adicionar("Maria");
console.log(armazenadorTextos.obter(0)); // "João"

A classe Armazenador<T> define que trabalhará com um tipo T. Quando você instancia Armazenador<number>, todos os métodos trabalham com number. Quando instancia Armazenador<string>, trabalham com string. Uma única implementação, múltiplas especializações.

Generics em Construtores e Métodos

Você pode combinar generics de classe com generics de método, criando flexibilidade ainda maior.

class Conversor<T> {
  constructor(private valor: T) {}

  converter<U>(transformar: (v: T) => U): U {
    return transformar(this.valor);
  }

  obterValor(): T {
    return this.valor;
  }
}

const conversor = new Conversor<number>(100);

// Converte number para string
const resultado = conversor.converter((num) => num.toString());
console.log(resultado); // "100"

// Converte number para boolean
const ehMaiorQueZero = conversor.converter((num) => num > 0);
console.log(ehMaiorQueZero); // true

Aqui, a classe é genérica no tipo T (o tipo armazenado), enquanto o método converter é genérico no tipo U (o tipo de saída). Isso permite transformações flexíveis mantendo segurança de tipo.

Restrições em Classes Genéricas

Assim como em funções, você pode restringir os tipos aceitos por uma classe genérica.

class Repositorio<T extends { id: number }> {
  private dados: T[] = [];

  salvar(item: T): void {
    this.dados.push(item);
  }

  buscarPorId(id: number): T | undefined {
    return this.dados.find((item) => item.id === id);
  }
}

interface Produto {
  id: number;
  nome: string;
  preco: number;
}

const repProdutos = new Repositorio<Produto>();
repProdutos.salvar({ id: 1, nome: "Notebook", preco: 3000 });
console.log(repProdutos.buscarPorId(1)); // { id: 1, nome: "Notebook", preco: 3000 }

// ERRO: tipo sem id não pode ser usado
// const repInvalido = new Repositorio<string>(); ❌

A restrição extends { id: number } garante que qualquer tipo usado com Repositorio tenha uma propriedade id do tipo number. Assim, o método buscarPorId é type-safe.

Generics em Interfaces

Interfaces Parametrizadas

Interfaces também podem ser genéricas, permitindo definir contratos flexíveis que funcionam com múltiplos tipos.

interface Paginado<T> {
  itens: T[];
  totalItens: number;
  paginaAtual: number;
  itensPorPagina: number;
}

interface Usuario {
  id: number;
  nome: string;
  email: string;
}

function procesarResultados(dados: Paginado<Usuario>): void {
  console.log(`Página ${dados.paginaAtual} com ${dados.itens.length} usuários`);
  dados.itens.forEach((usuario) => {
    console.log(`${usuario.nome} - ${usuario.email}`);
  });
}

const resultado: Paginado<Usuario> = {
  itens: [
    { id: 1, nome: "Alice", email: "alice@example.com" },
    { id: 2, nome: "Bob", email: "bob@example.com" },
  ],
  totalItens: 100,
  paginaAtual: 1,
  itensPorPagina: 2,
};

procesarResultados(resultado);

A interface Paginado<T> define a estrutura de dados paginados para qualquer tipo T. Reutilize-a com Usuario, Produto, Post, etc., sem duplicar a definição.

Interfaces com Métodos Genéricos

Interfaces podem incluir métodos genéricos dentro de sua assinatura.

interface Transformador<T, U> {
  transformar(entrada: T): U;
  transformarInverso(saida: U): T;
}

class StringParaNumero implements Transformador<string, number> {
  transformar(entrada: string): number {
    return parseInt(entrada, 10);
  }

  transformarInverso(saida: number): string {
    return saida.toString();
  }
}

const transformador = new StringParaNumero();
console.log(transformador.transformar("42")); // 42
console.log(transformador.transformarInverso(100)); // "100"

A interface Transformador<T, U> define um contrato entre um tipo entrada T e um tipo saída U. A classe implementadora deve respeitar essa assinatura.

Herança e Combinação de Genéricos

Interfaces genéricas podem estender outras interfaces genéricas, criando hierarquias de tipos complexas e reutilizáveis.

interface Entidade<T> {
  id: T;
  criadoEm: Date;
}

interface ComAtualizacao<T> extends Entidade<T> {
  atualizadoEm?: Date;
}

interface Ativo<T> extends ComAtualizacao<T> {
  ativo: boolean;
}

interface Artigo extends Ativo<number> {
  titulo: string;
  conteudo: string;
}

const artigo: Artigo = {
  id: 1,
  titulo: "TypeScript Generics",
  conteudo: "...",
  criadoEm: new Date(),
  atualizadoEm: new Date(),
  ativo: true,
};

Neste exemplo, Artigo herda de Ativo<number>, que herda de ComAtualizacao<number>, que herda de Entidade<number>. A composição permite reutilização e especificação progressiva do tipo.

Padrões Avançados com Generics

Tipos Condicionais

Tipos condicionais permitem selecionar diferentes tipos baseado em uma condição. Eles usam a sintaxe T extends U ? X : Y.

type Tipo<T> = T extends string ? "texto" : T extends number ? "numero" : "outro";

type A = Tipo<string>; // "texto"
type B = Tipo<number>; // "numero"
type C = Tipo<boolean>; // "outro"

function processar<T>(valor: T): Tipo<T> {
  if (typeof valor === "string") {
    return "texto" as Tipo<T>;
  } else if (typeof valor === "number") {
    return "numero" as Tipo<T>;
  }
  return "outro" as Tipo<T>;
}

Tipos condicionais são poderosos para criar tipos que se adaptam baseado no tipo de entrada, permitindo APIs mais inteligentes e type-safe.

Mapeamento de Tipos (Mapped Types)

Mapped types transformam propriedades de um tipo existente em um novo tipo. Isso reduz duplicação de tipos.

interface Usuario {
  nome: string;
  email: string;
  idade: number;
}

// Todos os campos são opcionais
type Parcial<T> = {
  [K in keyof T]?: T[K];
};

type UsuarioParcial = Parcial<Usuario>;
// Equivalente a: { nome?: string; email?: string; idade?: number; }

const usuarioPartial: UsuarioParcial = { nome: "João" }; // Válido

// Todos os campos somente leitura
type Somente<T> = {
  readonly [K in keyof T]: T[K];
};

type UsuarioSomente = Somente<Usuario>;
const usuarioSomente: UsuarioSomente = {
  nome: "Maria",
  email: "maria@example.com",
  idade: 30,
};
// usuarioSomente.nome = "Ana"; // ERRO: readonly

Mapped types são ferramentas avançadas que permitem criar tipos derivados automaticamente, economizando linhas de código e mantendo sincronização com tipos base.

Conclusão

Generics são fundamentais para escrever código TypeScript profissional e reutilizável. Primeiro, eles eliminam a necessidade de any e duplicação de código, permitindo que uma única implementação funcione com múltiplos tipos enquanto mantém segurança em tempo de compilação. Segundo, dominando funções genéricas, classes genéricas e interfaces genéricas, você consegue criar APIs claras, previsíveis e fáceis de usar, tanto para você quanto para outros desenvolvedores. Terceiro, padrões avançados como tipos condicionais e mapped types são potentes para cenários complexos, mas aprenda o básico primeiro — a maioria dos casos práticos resolver-se com generics simples e restrições de tipo bem definidas.

Referências


Artigos relacionados