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