Generics em interfaces e classes

1. Introdução aos Generics em Estruturas de Dados

Generics permitem que interfaces e classes trabalhem com tipos de forma parametrizada, oferecendo reutilização de código sem sacrificar a segurança de tipos. Enquanto tipos concretos fixam uma estrutura específica, tipos parametrizados permitem que a mesma definição funcione com diferentes tipos.

Considere a diferença entre uma interface que só aceita números e uma genérica:

// Tipo concreto - funciona apenas com number
interface CaixaNumerica {
  conteudo: number;
}

// Tipo parametrizado - funciona com qualquer tipo
interface Caixa<T> {
  conteudo: T;
}

const caixaString: Caixa<string> = { conteudo: "olá" };
const caixaNumero: Caixa<number> = { conteudo: 42 };

O mesmo princípio se aplica a classes. Uma Pilha<T> pode armazenar qualquer tipo de elemento:

class Pilha<T> {
  private elementos: T[] = [];

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

  pop(): T | undefined {
    return this.elementos.pop();
  }

  topo(): T | undefined {
    return this.elementos[this.elementos.length - 1];
  }
}

const pilhaNumeros = new Pilha<number>();
pilhaNumeros.push(10);
pilhaNumeros.push(20);

const pilhaStrings = new Pilha<string>();
pilhaStrings.push("TypeScript");

2. Interfaces Genéricas: Definição e Uso

Interfaces genéricas definem contratos que podem ser implementados para tipos específicos. Um exemplo clássico é um repositório de dados:

interface Repositorio<T> {
  salvar(item: T): void;
  buscar(id: string): T;
  listar(): T[];
  remover(id: string): void;
}

Implementação concreta para usuários:

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

class RepositorioUsuario implements Repositorio<Usuario> {
  private dados: Usuario[] = [];

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

  buscar(id: string): Usuario {
    const usuario = this.dados.find(u => u.id === id);
    if (!usuario) throw new Error("Usuário não encontrado");
    return usuario;
  }

  listar(): Usuario[] {
    return [...this.dados];
  }

  remover(id: string): void {
    this.dados = this.dados.filter(u => u.id !== id);
  }
}

Múltiplos parâmetros de tipo são úteis para estruturas como pares chave-valor:

interface Par<K, V> {
  chave: K;
  valor: V;
}

const par1: Par<string, number> = { chave: "idade", valor: 30 };
const par2: Par<number, boolean> = { chave: 1, valor: true };

3. Classes Genéricas: Propriedades, Métodos e Construtores

Classes genéricas podem usar o parâmetro de tipo em propriedades, métodos e construtores:

class Lista<T> {
  private itens: T[] = [];

  constructor(...itensIniciais: T[]) {
    this.itens.push(...itensIniciais);
  }

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

  obter(indice: number): T {
    if (indice < 0 || indice >= this.itens.length) {
      throw new Error("Índice inválido");
    }
    return this.itens[indice];
  }

  filtrar(predicado: (item: T) => boolean): T[] {
    return this.itens.filter(predicado);
  }

  get tamanho(): number {
    return this.itens.length;
  }
}

// Inferência de tipo na instanciação
const lista = new Lista("a", "b", "c"); // Tipo inferido: Lista<string>
const listaNumeros = new Lista<number>(1, 2, 3);

4. Constraints em Interfaces e Classes Genéricas

Constraints permitem restringir quais tipos podem ser usados com um generic, garantindo acesso a propriedades específicas:

interface Identificavel {
  id: string;
}

class Repositorio<T extends Identificavel> {
  private itens: T[] = [];

  adicionar(item: T): void {
    // Podemos acessar item.id com segurança
    const existe = this.itens.find(i => i.id === item.id);
    if (existe) {
      throw new Error(`Item com id ${item.id} já existe`);
    }
    this.itens.push(item);
  }

  buscar(id: string): T | undefined {
    return this.itens.find(item => item.id === id);
  }
}

interface Produto extends Identificavel {
  nome: string;
  preco: number;
}

const repositorioProdutos = new Repositorio<Produto>();
repositorioProdutos.adicionar({ id: "p1", nome: "Teclado", preco: 150 });

Constraints também funcionam com tipos primitivos e interfaces complexas:

class Pilha<T extends { id: string; toString(): string }> {
  private itens: T[] = [];

  push(item: T): void {
    console.log(`Adicionando: ${item.toString()}`);
    this.itens.push(item);
  }

  pop(): T | undefined {
    return this.itens.pop();
  }
}

class Item {
  constructor(public id: string, public valor: string) {}
  toString(): string {
    return `${this.id}: ${this.valor}`;
  }
}

const pilhaItens = new Pilha<Item>();
pilhaItens.push(new Item("1", "Exemplo"));

5. Herança e Composição com Tipos Genéricos

Classes genéricas podem estender outras classes genéricas, e interfaces podem estender interfaces genéricas:

interface Colecao<T> {
  adicionar(item: T): void;
  remover(item: T): boolean;
}

interface ColecaoOrdenada<T> extends Colecao<T> {
  ordenar(comparador: (a: T, b: T) => number): void;
}

class ListaOrdenada<T> implements ColecaoOrdenada<T> {
  private itens: T[] = [];

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

  remover(item: T): boolean {
    const index = this.itens.indexOf(item);
    if (index === -1) return false;
    this.itens.splice(index, 1);
    return true;
  }

  ordenar(comparador: (a: T, b: T) => number): void {
    this.itens.sort(comparador);
  }
}

Composição com tipos genéricos aninhados:

class Container<T> {
  constructor(public valor: T) {}
}

class CaixaCompartimentada<T, U> {
  constructor(
    public compartimento1: Container<T>,
    public compartimento2: Container<U>
  ) {}
}

const caixa = new CaixaCompartimentada(
  new Container("texto"),
  new Container(42)
);

6. Métodos Estáticos e Genéricos em Classes

Métodos estáticos não podem acessar o parâmetro de tipo da classe, mas podem definir seus próprios parâmetros genéricos:

class Lista<T> {
  private itens: T[] = [];

  constructor(...itens: T[]) {
    this.itens.push(...itens);
  }

  // Método estático com seu próprio parâmetro genérico
  static criar<T>(...args: T[]): Lista<T> {
    return new Lista<T>(...args);
  }

  // Método de instância normal
  adicionar(item: T): void {
    this.itens.push(item);
  }
}

// Uso do método estático genérico
const lista1 = Lista.criar(1, 2, 3); // Lista<number>
const lista2 = Lista.criar("a", "b"); // Lista<string>

Outro exemplo com factory estático:

class Par<K, V> {
  constructor(
    public chave: K,
    public valor: V
  ) {}

  static deArray<T>(items: T[]): Par<number, T>[] {
    return items.map((item, index) => new Par(index, item));
  }
}

const pares = Par.deArray(["azul", "verde", "vermelho"]);
// Tipo: Par<number, string>[]

7. Padrões e Boas Práticas com Generics

Convenções de nomenclatura comuns:

Nome Uso típico
T Tipo genérico principal
K Chave (Key)
V Valor (Value)
U Segundo tipo genérico
TItem Tipo específico de item

Exemplos do mundo real:

// Observable Pattern
class Observable<T> {
  private observadores: Array<(dado: T) => void> = [];

  inscrever(callback: (dado: T) => void): void {
    this.observadores.push(callback);
  }

  notificar(dado: T): void {
    this.observadores.forEach(cb => cb(dado));
  }
}

// API Response pattern
interface ApiResponse<T> {
  dados: T;
  mensagem: string;
  sucesso: boolean;
  codigo: number;
}

async function buscarUsuario(id: string): Promise<ApiResponse<{ nome: string; email: string }>> {
  const resposta = await fetch(`/api/usuarios/${id}`);
  return resposta.json();
}

Quando evitar generics:

  • Se a interface ou classe só funcionará com um tipo específico
  • Se o código ficar mais complexo sem benefício real
  • Se a generalização prejudicar a legibilidade
// Evite: supergeneralização desnecessária
interface Container<T, U, V> {
  item1: T;
  item2: U;
  item3: V;
}

// Prefira: específico quando só há um caso de uso
interface Endereco {
  rua: string;
  numero: number;
  cidade: string;
}

Generics em interfaces e classes são ferramentas poderosas para criar código reutilizável e type-safe. Use-os quando precisar de flexibilidade sem abrir mão do sistema de tipos do TypeScript.

Referências