Introdução a generics com TypeScript
- b7web
- Javascript
- mar 06, 2022
O TypeScript é uma linguagem que vem se tornando bastante popular nos últimos tempos, e uma de suas features mais interessantes são os generics, e é sobre eles que iremos falar hoje.
O que é um generic?
Um generic é como se fosse um parâmetro (ou variável) para os nossos tipos, quando nós começamos a utilizar uma linguagem tipada, nós geralmente começamos com os tipos básicos como string ou boolean, mas existem alguns tipos que são mais complexos, esses tipos geralmente são tipos que são compostos de outros tipos.
E em alguns casos desses tipos compostos, é interessante que eles possam funcionar com vários tipos diferentes, por exemplo um array deveria funcionar com qualquer tipo que você coloque dentro dele e não apenas com um tipo específico.
Portanto, imagine que nós precisamos criar uma estrutura de dados semelhante a um array, por exemplo uma lista, em TypeScript, o nosso código poderia ser mais ou menos assim:
class List {
private items: any[] = [];
public add(item: any): void {
this.items.push(item);
}
public set(position: number, item: any): void {
this.items[position] = item;
}
public get(position: number): any {
return this.items[position];
}
public remove(position: number): void {
this.items.splice(position, 1);
}
public size(): number {
return this.items.length;
}
public toString(): string {
return this.items.join(", ");
}
}
Mas perceba que em alguns pontos, nós colocamos o tipo any
que significa que nós estamos dizendo para o compilador do TypeScript que ele deve ignorar aquela parte do código, e geralmente utilizar o tipo any
é uma má prática, então não queremos deixar ele assim.
Para observarmos um dos vários problemas que teríamos ao utilizar o any
na tipagem dos métodos, podemos imaginar o seguinte caso:
const list = new List();
list.add("Bob");
list.add("Suzan");
list.add("Sara");
console.log(list.toString()); // Bob, Suzan, Sara
Aqui temos uma lista só de strings, mas perceba que para mantermos ela como uma lista só de strings, nós teríamos que confiar no programador que está utilizando a nossa classe para fazer isso, não temos uma segurança a nível de tipagem para auxiliar nisso, e ter essa segurança a nível de tipagem normalmente é o nosso objetivo ao utilizar o TypeScript.
Então como poderíamos fazer para garantir essa segurança a nível de tipagem? Um jeito simples, seria trocar todos os any, por string na hora de tiparmos a nossa classe. Ex:
class List {
private items: string[] = [];
public add(item: string): void {
this.items.push(item);
}
public get(position: number): string {
return this.items[position];
}
public set(position: number, item: string): void {
this.items[position] = item;
}
public remove(position: number): void {
this.items.splice(position, 1);
}
public size(): number {
return this.items.length;
}
toString(): string {
return this.items.join(", ");
}
}
Legal, só que e se não quisessemos utilizar uma lista de strings, e sim uma de number? Uma solução seria copiar e colar nossa classe List e manter duas classes, por exemplo: NumberList, StringList, cada uma sendo igual a outra porém trabalhando com tipos diferentes para os itens que elas podem conter. Seria bem ineficiente não é? Meio que teríamos que gerar a mesma classe para cada tipo possível do nosso sistema, isso seria um absurdo para dar manutenção.
Então como poderíamos de fato tornar a nossa classe List genérica o suficiente para podermos utiliziar qualquer coisa dentro dela, porém mantendo a segurança de tipos do TypeScript? Simples, poderíamos utilizar os generics para isso. Ex:
class List<T> {
private items: T[] = [];
public add(item: T): void {
this.items.push(item);
}
public get(position: number): T {
return this.items[position];
}
public set(position: number, item: T): void {
this.items[position] = item;
}
public remove(position: number): void {
this.items.splice(position, 1);
}
public size(): number {
return this.items.length;
}
toString(): string {
return this.items.join(", ");
}
}
E assim na hora de utilizar a nossa classe List, nós podemos informar ao TS no momento da instânciação qual o tipo daquela lista. Ex:
// O <string> indica que aquele tipo T da nossa classe
// agora vale string, então tudo que usava o T agora substitui
// aquele T por string, e portanto nossa lista só funciona com string
const list = new List<string>();
list.add("Bob");
list.add("Suzan");
list.add("Sara");
console.log(list.toString()); // Bob, Suzan, Sara
// O <number> indica que aquele tipo T da nossa classe
// agora vale number, então tudo que usava o T agora substitui
// aquele T por number, e portanto nossa lista só funciona com number
const list = new List<number>();
list.add(1);
list.add(2);
list.add(3);
console.log(list.toString()); // 1, 2, 3
Perceba que quando criamos a nossa classe, uma nova coisa foi adicionada, aquele diamante (<>) com um T depois do nome da classe. Esse diamante define que nós vamos ter generics na nossa classe, e o T é o nosso generic, ele é como se fosse um parâmetro de uma função só que nesse caso é um parâmetro do nosso tipo List, ele vai ser substituido pelo tipo definido na hora que a classe for instânciada e assim ela vai se comportar como se o T fosse o tipo definido lá na hora da instânciação, lembrando que assim como parâmetros aquele T poderia ser qualquer coisa, nós que damos o nome dele.
Onde podemos utilizar generics?
No nosso exemplo podemos notar que definimos generics em uma classe, mas podemos utiliza-los em outros contextos? A resposta é sim, podemos definir um generic em qualquer lugar que podemos criar um tipo. Ex:
// Podemos utilizar na criação de type aliases
type Wrapper<T> = { value: T }
// Podemos utilizar na criação de interfaces
interface IList<T> {
add(item: T): void;
get(position: number): T;
set(position: number, item: T): void;
remove(position: number): void;
size(): number;
}
// Podemos utilizar na criação de classes
// Inclusive podemos utilizar esse generic da lista
// para parametrizar a nossa interface
class List<T> implements IList<T> {
private items: T[] = [];
public add(item: T): void {
this.items.push(item);
}
public get(position: number): T {
return this.items[position];
}
public set(position: number, item: T): void {
this.items[position] = item;
}
public remove(position: number): void {
this.items.splice(position, 1);
}
public size(): number {
return this.items.length;
}
toString(): string {
return this.items.join("\\n");
}
}
// Podemos utilizar na criação de funções
const identity = <T>(value: T): T => value;
function otherIdentity<T>(value: T): T {
return value;
}
Utilizando valores padrão
Legal, já entendemos onde e porque utilizar os generics, mas e se nós quisermos definir um generic que tenha um tipo padrão, assim não precisaríamos passar ele toda vez que pudessemos utilizar o nosso generic? Isso também é possível, então vamos utilizar isso no nosso exemplo da classe List:
class List<T = any> {
private items: T[] = [];
public add(item: T): void {
this.items.push(item);
}
public get(position: number): T {
return this.items[position];
}
public set(position: number, item: T): void {
this.items[position] = item;
}
public remove(position: number): void {
this.items.splice(position, 1);
}
public size(): number {
return this.items.length;
}
toString(): string {
return this.items.join("\\n");
}
}
const list = new List();
Como não definimos nenhum parâmetro na hora de instânciar a nossa classe List, ele vai utilizar o any como padrão, dessa forma nossa constante list vale como se fosse List<any>.
Utilizando generic constraints
E para fechar o nosso artigo vamos falar de um outro caso. O caso onde poderíamos querer um tipo genérico, mas que não seja tão genérico assim, por exemplo, e se nós quisessemos criar uma função que recebe um parametro que vai ser um generic, mas queremos uma condição mínima para esse generic, por exemplo qualquer objeto que tenha a propriedade name, como poderíamos fazer isso? Simples podemos utilizar as generics constraints:
interface Nameable {
name: string;
}
function logObject<T extends Nameable>(obj: T) {
console.log(obj.name);
}
Nossa função logObject
aceita um parâmetro genérico T
porém por causa da parte extends Nameable
nós definimos que T
deve ser no mínimo o tipo Nameable
, logo ele vai aceitar qualquer tipo que tenha a propriedade name, mas não vai deixar o nosso tipo T
preso somente ao tipo Nameable
.
Obrigado por ler mais esse artigo, e até a próxima.