Generics em TypeScript
TypeScript é uma linguagem fortemente tipada, mas, às vezes, é necessário construir funcionalidades que possam utilizar dados de qualquer tipo. Em alguns casos, poderíamos usar o tipo any
:
function getId(id: any): any {
return id;
}
let result = getId(5); // neste caso, result é do tipo any
console.log(result);
Entretanto, nesse caso, não podemos usar o resultado da função como um objeto do tipo que foi passado para a função; para nós, ele é do tipo any
. Se, em vez do número 5, fosse passado um objeto de alguma classe e, posteriormente, precisássemos usar esse objeto, por exemplo, chamando seus métodos, isso seria problemático. Para especificar o tipo de retorno, podemos usar generics:
function getId<T>(id: T): T {
return id;
}
Com a expressão <T>
, indicamos que a função getId
é tipada com um certo tipo T
(pode ser qualquer letra, mas geralmente é usada a letra T
). No momento em que escrevemos a função, podemos não saber qual será esse tipo. Ao chamar a função, um tipo concreto substituirá T
. Assim, a função retornará um objeto desse tipo. Por exemplo:
function getId<T>(id: T): T {
return id;
}
let result1 = getId<number>(5);
console.log(result1);
let result2 = getId<string>("abc");
console.log(result2);
No primeiro caso, o tipo number
será usado em vez do parâmetro T
, permitindo-nos passar um número para a função. No segundo caso, o tipo string
é usado em vez de T
, permitindo passar uma string. Dessa forma, podemos passar para a função objetos de diferentes tipos, mas mantendo a tipagem forte; cada variante da função genérica pode aceitar objetos apenas de um tipo específico.
Da mesma forma, podemos usar arrays genéricos:
function getString<T>(args: Array<T>): string {
return args.join(", ");
}
let result = getString<number>([1, 2, 34, 5]);
console.log(result);
Nesse caso, independentemente do tipo de dados passados no array, a função irá retornar uma string.
Classes e Interfaces Genéricas
Além de funções e arrays genéricos, também existem classes e interfaces genéricas:
class User<T> {
private _id: T;
constructor(id: T) {
this._id = id;
}
getId(): T {
return this._id;
}
}
let tom = new User<number>(3);
console.log(tom.getId()); // retorna number
let alice = new User<string>("vsf");
console.log(alice.getId()); // retorna string
No entanto, nesse caso, é preciso considerar que, se tipificamos o objeto com um determinado tipo, não será possível alterar esse tipo posteriormente. Ou seja, no exemplo a seguir, a segunda criação do objeto não funcionará, pois o objeto tom
já está tipado como number
:
let tom = new User<number>(3);
console.log(tom.getId());
tom = new User<string>("vsf"); // erro
O mesmo vale para interfaces:
interface IUser<T> {
getId(): T;
}
class User<T> implements IUser<T> {
private _id: T;
constructor(id: T) {
this._id = id;
}
getId(): T {
return this._id;
}
}
Restringindo Generics
Os generics permitem trabalhar com qualquer tipo de dado. No entanto, às vezes é necessário usar não qualquer tipo, mas apenas um certo conjunto de tipos que atendam a determinados critérios. Por exemplo, consideremos a seguinte função:
function compareName<T>(obj1: T, obj2: T): void {
if (obj1.name === obj2.name) {
console.log("Nomes são iguais");
} else {
console.log("Nomes são diferentes");
}
}
A função recebe dois objetos cujo tipo é desconhecido. No código da função, são comparados os valores das propriedades name
desses objetos.
Vamos tentar usar essa função para comparar dois objetos que possuem a propriedade name
:
let tom: { name: string } = { name: "Tom" };
let sam: { name: string } = { name: "Sam" };
compareName<{ name: string }>(tom, sam);
Aqui, estamos comparando dois objetos, tom
e sam
, que têm o mesmo tipo { name: string }
. Ou seja, ambos os objetos possuem a propriedade name
.
Ao chamar a função, compareName()
é tipada com esse tipo { name: string }
. Pareceria que não deveria haver problemas. No entanto, ao compilar, receberemos o erro:
Property 'name' does not exist on type 'T'
O uso de generics que se adequam a qualquer tipo amplia o conjunto de tipos utilizados, mas limita sua aplicação.
As restrições (constraints) permitem limitar o conjunto de tipos que podem ser usados nos generics. As restrições são definidas na forma:
<T extends CritérioDeTipos>
Após o nome do parâmetro (neste caso, T
), vem a palavra-chave extends
, seguida pelo critério que os tipos de dados devem atender ao serem passados no lugar do parâmetro T
.
Por exemplo, no caso da função compareName()
do exemplo acima, os tipos devem ter a propriedade name
. Portanto, vamos reescrever a função da seguinte maneira:
function compareName<T extends { name: string }>(obj1: T, obj2: T): void {
if (obj1.name === obj2.name) {
console.log("Names are the same");
} else {
console.log("Names are different");
}
}
let tom: { name: string } = { name: "Tom" };
let sam: { name: string } = { name: "Sam" };
compareName<{ name: string }>(tom, sam);
A notação <T extends { name: string }>
significa que o parâmetro T deve representar um tipo que contenha a propriedade name, como no caso dos objetos tom e sam.
Além disso, o parâmetro T
não precisa necessariamente representar exatamente o tipo { name: string }
. Por exemplo:
function compareName<T extends { name: string }>(obj1: T, obj2: T): void {
if (obj1.name === obj2.name) {
console.log("Names are the same");
} else {
console.log("Names are different");
}
}
class User {
constructor(public name: string, public age: number) {}
}
let bob = new User("Bob", 38);
let bobic = new User("Bob", 24);
compareName<User>(bob, bobic);
type Person = { id: number; name: string };
let tom: Person = { id: 1, name: "Tom" };
let sam: Person = { id: 2, name: "Sam" };
compareName<Person>(tom, sam);
Aqui, na primeira chamada, a função compareName()
é tipada com a classe User
, ou seja, os objetos passados para ela devem ser instâncias da classe User
. Na segunda chamada, a função é tipada com o tipo Person
, que representa o objeto { id: number; name: string }
. Tanto User
quanto Person
têm em comum a propriedade name
e, portanto, atendem à restrição { name: string }
.
Além disso, qualquer tipo pode ser usado, por exemplo, interfaces:
interface Named {
name: string;
}
function compareName<T extends Named>(obj1: T, obj2: T): void {
if (obj1.name === obj2.name) {
console.log("Nomes são iguais");
} else {
console.log("Nomes são diferentes");
}
}
Da mesma forma, as restrições em generics podem ser aplicadas em interfaces e classes:
interface Named {
name: string;
}
class NameInfo<T extends Named> {
printName(obj: T): void {
console.log(`Name: ${obj.name}`);
}
}
class User {
constructor(public name: string, public age: number) {}
}
let bob = new User("Bob", 38);
let nameInfo1 = new NameInfo<User>();
nameInfo1.printName(bob);
type Person = { id: number; name: string };
let tom: Person = { id: 1, name: "Tom" };
let nameInfo2 = new NameInfo<Person>();
nameInfo2.printName(tom);
Nesse caso, a classe NameInfo
utiliza um parâmetro de tipo T
, que é restrito pela interface Named
.
Assim, podemos tipar objetos da classe NameInfo
com qualquer tipo que tenha a propriedade name
, como a classe User
ou o tipo Person
.
Usando a Palavra-chave new
Para criar um novo objeto em código genérico, precisamos indicar que o tipo genérico T
possui um construtor. Isso significa que, em vez do parâmetro type: T
, precisamos especificar type: { new (): T }
. Por exemplo:
function userFactory<T>(type: { new (): T }): T {
return new type();
}
class User {
constructor() {
console.log("Objeto User criado");
}
}
let user: User = userFactory(User);
Nesse caso, a função userFactory
utiliza um parâmetro type
que deve ser um construtor de T
. Assim, podemos criar novas instâncias do tipo genérico T
especificando que T
possui um construtor.