Tipos genéricos

Imagine a seguinte condição: eu tenho uma classe que possui um array/vetor. Em cada instância dessa classe, eu preciso que o vetor seja de um tipo diferente. Embora a linguagem TypeScript ofereça o tipo primitivo any (qualquer tipo), que pode atender a essa demanda, seu uso indiscriminado não é interessante.

💡 Entendendo o conceito

Podemos usar, em classes e interfaces, o conceito de generics (tipos genéricos). A sua utilização é bastante simples, marcada por uma tag <T> (embora T seja uma convenção, você pode trocar por outra letra ou termo):

export class Pessoa<T>{
    private _nome: string;
    private _lista: T[];
    
    constructor(nome: string, lista: T[]){
        this._nome = nome;
        this._lista = lista;
    }
    
    get nome(){
        return this._nome;
    }
    set nome(nome: string){
        this._nome = nome;
    }
    get lista(){
        return this._lista;
    }
    set lista(lista: T[]){
        this._lista = lista;
    }
    adicionar(item: T){
        this._lista.push(item);
    }
}

Agora, vamos imaginar que queremos instanciar objetos da classe Pessoa com listas de diferentes tipos:

import { Pessoa } from "./Pessoa";

/* Instanciando pessoa com lista de number */
let joao = new Pessoa<number>("João", []);
joao.adicionar(5);
joao.adicionar(10);
console.log(joao.lista);

/* Instanciando pessoa com uma lista de string */
let pedro = new Pessoa<string>("Pedro", []);
pedro.adicionar("Cuiaba");
pedro.adicionar("Brasil");
console.log(pedro.lista);

Podemos incluir mais de um tipo genérico numa classe ou interface, como no exemplo a seguir:

class Pessoa<T,U>{
    private _nome: string;
    private _lista: T[];
    private _itens: U[];
    //... 
}

Agora, para usar, informamos os tipos separados por vírgula:

let joao = new Pessoa<string, number>(Joao, ["Cuiaba"], [10]);

🎯 Usando tipos genéricos em interfaces

O uso de tipos genéricos em interfaces é ainda mais interessante, pois pode proporcionar maior flexibilidade quando lidamos com diferentes tipos de dados. Imagine a interface a seguir, que contempla operações básicas para uma lista:

export interface ILista<T> {
  adicionarItem(item: T): void;
  buscarItemPorNome(item: string): T;
  removerItem(item: T): void;
  obterQuantidade(): number;
}

Ela pode ser utilizada para garantir que diferentes classes implementem essas operações para suas listas de tipo T. Veja, por exemplo, o caso da classe ControleProduto:

export class ControleProduto implements ILista<Produto> {
  private produtos: Produto[] = [];
  
  adicionarItem(item: Produto): void {
    this.produtos.push(item);
  }  
  buscarItemPorNome(nome: string): Produto {
    return this.produtos.find(produto => produto.nome === nome);
  }
  removerItem(item: Produto): void {
    const index = this.produtos.indexOf(item);
    if (index !== -1) {
      this.produtos.splice(index, 1);
    }
  }
  obterQuantidade(): number {
    return this.produtos.length;
  }
}

A mesma estrutura de métodos pode ser utilizada pela classe ControleCliente:

export class ControleCliente implements ILista<Cliente> {
  private clientes: Cliente[] = [];
  
  adicionarItem(item: Cliente): void {
    this.clientes.push(item);
  }
  
  buscarItemPorNome(nome: string): Cliente {
    const clienteEncontrado = this.clientes.find(cliente => cliente.nome === nome);
    if (!clienteEncontrado) { 
        console.log(`Cliente com nome ${nome} não encontrado.`);
    }
    return clienteEncontrado;
  }

  removerItem(item: Cliente): void {
    const index = this.clientes.indexOf(item);
    if (index !== -1) {
      this.clientes.splice(index, 1);
    } else {
      console.log('Cliente não encontrado para remoção.');
    }
  }

  obterQuantidade(): number {
    return this.clientes.length;
  }
}

Observe que essa abordagem traz organização: os métodos de manipulação da lista apresentam mesmo nome nas diversas classes.

🕵️‍♂️ Tipos genéricos compatíveis com interfaces

Uma outra possibilidade que podemos explorar neste caso envolve especificar que o tipo genérico deve ser compatível com determinada interface. Essa estratégia é intessante para garantir, por exemplo, que os objetos do tipo T possuam determinado método ou atributo.

Veja a interface a seguir, que estabelece a obrigatoriedade do método nas classes que a implementarem:

export interface IExemplo{
    // Implementação da interface

    metodoNecessario(): void;
}

Na classe Pessoa, especificamos que o tipo genérico T deve implementar a interface IExemplo, usando T extends IExemplo:

import { IExemplo } from "./IExemplo";

export class Pessoa<T extends IExemplo>{
    private _nome: string;
    private _lista: T[];
    //...

    public algumaOperacao(){
        this._lista.forEach((item) => item.metodoNecessario());
    }
}

Ao tentarmos instaciar a classe Pessoa, precisaremos passar como tipo T um tipo que cumpra os requisitos estabelecidos pela interface. O exemplo a seguir envolverá erro, pois number não implementa a interface IExemplo:

import { Pessoa } from "./Pessoa";

let obj = new Pessoa<number>(); // erro, pois number não tem metodoNecessario()

Para mostrar um caso positivo, vamos criar uma classe chamada Produto. Essa classe, embora não implemente explicitamente a interface, cumpre o requisito: possui metodoNecessario() e será aceita, como você poderá observar no código a seguir.

export class Produto{
    public metodoNecessario(): void{
        console.log("metodo necessario");
    }
}
import { Pessoa } from "./Pessoa";
import { Produto } from "./Produto";

let obj = new Pessoa<Produto>(); // pode, pois a classe Produto tem os métodos requeridos pela interface, mesmo não a implementando

🚀 Para testar na prática