Qué son los genéricos en TypeScript, conocidos habitualmente como generics, una utilidad de TypeScript muy relacionada con su sistema de tipado.
En este artículo vamos a abordar el concepto de generics en el lenguaje TypeScript y ver algunos ejemplos sencillos. Es algo que no existe en Javascript y que por tanto tenemos a nuestra disposición solamente cuando desarrollamos en TypeScript. Los genéricos son una utilidad que disponemos en el lenguaje en tiempo de desarrollo, o de compilación, como las interfaces. Es decir, una vez compilado el código, ya en Javascript, no existe tal construcción y por lo tanto no nos podrán ayudar en tiempo de ejecución.
Los genéricos son construcciones ya disponibles en lenguajes como C# o Java, por lo que posiblemente ya sepas a lo que nos estamos refiriendo. Tengas o no idea, esperamos ayudarte aclarando el concepto, para ver un poco de código para aclarar su sintaxis y uso.
Qué son los genéricos
Podemos entender los genéricos como una especie de "plantilla" de código, mediante la cual podemos aplicar un tipo de datos determinado a varios puntos de nuestro código. Sirven para aprovechar código, sin tener que duplicarlo por causa de cambios de tipo y evitando la necesidad de usar el tipo "any".
Así dichas las afirmaciones anteriores, quizás no te digan demasiado. En cambio, la manera más sencilla de entender los genéricos es mediante un ejemplo.
Imagina que tienes una función que muestra un valor numérico y luego lo devuelve. Más tarde te piden hacer esa misma funcionalidad, pero con un string. Para conseguirlo tenemos dos alternativas:
a.- Crear dos funciones con declaraciones de tipos distintas
function display(valor: number): number {
console.log(valor);
return valor;
}
function displayString(valor: string): string {
console.log(valor);
return valor;
}
El problema de esta alternativa es obvio, pues hemos repetido prácticamente el mismo código dos veces. Incluso aunque en una clase se aplicase sobrecarga, el código sería prácticamente el mismo.
b.- Usar una única función en la que recibimos y devolvemos el tipo any
function display(valor: any): any {
console.log(valor);
return valor;
}
Ahora el problema es que "any" no nos ayuda para nada en TypeScript. No nos advierte si nos equivocamos asignando otros tipos de datos y el editor deja de ayudarnos con el intellisense.
La solución pasa por aplicar los genéricos
Mediante genéricos podemos indicar que aquello que se recibe tiene un tipo maleable. Es decir, puede admitir diversos tipos, pero sin embargo, dentro de la función podemos referirnos al tipo que se está admitiendo, para que el compilador y el editor nos ayuden en donde puedan.
Sintaxis de los generic de TypeScript
Los genéricos se indican entre "menores y mayores que", como si fueran etiquetas HTML, asignando un alias al tipo variable que se esté recibiendo. Luego podemos usar ese alias para definir el tipo.
function display<elTipo>(valor: elTipo): elTipo {
console.log(valor);
return valor;
}
Aquí estamos definiendo el genérico "elTipo". Esto nos permite que el tipo de parámetro se indique con el alias, así como el tipo de valor devuelto. En la práctica hará que el tipo del valor devuelto se pueda deducir del tipo del valor recibido.
Gracias a esta situación nos conseguimos librar de la declaración de tipo "any", que no ayudaba para nada, y sustituirla por un generic. Aunque es verdad que no ayudan igual que si fuera un tipo escrito a fuego, sí representan algunas ayudas interesantes como podemos ver en la siguiente imagen:
Como ves, aquí el editor es capaz de inferir el tipo de la variable "dato", ya que lo que devuelve la función display() es del mismo tipo del valor que recibió.
Por ejemplo, aquí el editor nos muestra un error por intentar asignar en una variable un dato de otro tipo.
Un genérico puede ser cualquier tipo, incluso una clase o un array
Cuando usamos un genérico podemos entregar cualquier tipo, incluso una clase de programación orientada a objetos.
class Animal {
}
function cuidar<T>(algo: T): T {
return algo;
}
let algo = cuidar(new Animal());
La variable "algo" en la última línea de código, quedaría inferida como de tipo Animal, ya que el parámetro enviado era un objeto de esa clase.
Existen diversas utilidades en generics que nos ayudan a definir el tipo con un rango más acotado.
Otro ejemplo interesante es el siguiente, en el que definimos que el parámetro será un array de elementos del tipo genérico.
function display<T>(valor: T[]): T[] {
console.log(valor.length);
return valor;
}
Como se ha declarado el parámetro como un array de elementos "T", es seguro que dentro de nuestra función podamos acceder a la propiedad "length" de ese parámetro.
No existe traducción a Javascript de un genérico
Los generics no tienen traducción a código Javascript, ya que Javascript no admite tipos. Como habíamos dicho, los genéricos solo te ayudan en tiempo de desarrollo.
Para observar este detalle, es interesante ver la traducción que el compilador de TypeScript hace de una función donde se usan genéricos. Por ejemplo:
function display<T>(valor: T): T {
console.log(valor);
return valor;
}
Se traduce a Javascript por este otro código:
function display(valor) {
console.log(valor);
return valor;
}
Dos genéricos en una cabecera de función
En la cabecera de una función también podemos definir dos tipos genéricos, separando por comas, tal como se puede ver en el siguiente código.
function prueba<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
Aquí hemos indicado que tendremos dos parámetros. El primero es genérico y podría ser de cualquier tipo, mientras que el segundo deberá ser un valor que se encuentre como llave (key) del primer parámetro. Por tanto, el primer parámetro tendría que ser un objeto y el segundo parámetro uno de las cadenas que sirven como llaves dentro de ese objeto.
let profesional = {
nombre: 'Miguel A A',
empresa: 'DesarrolloWeb.com'
}
console.log(prueba(profesional, 'empresa')); // Perfecta invocación
console.log(prueba(profesional, 'propiedadInexistente')); // invocación incorrecta, porque la propiedad no pertenece al objeto
La última línea dará un error en compilación, dado que no existe la "propiedadInexistente" como propiedad del objeto recibido como primer parámetro.
let profesional = {
nombre: 'Miguel A A',
empresa: 'DesarrolloWeb.com'
}
console.log(prueba(profesional, 'empresa')); // Perfecta invocación
console.log(prueba(profesional, 'propiedadInexistente')); // invocación incorrecta, porque la propiedad no pertenece al objeto
Clases genéricas
El conjunto de recursos disponibles con genéricos aumenta cuando los usamos con clases. Así podemos tener una clase en la que declaremos el uso de un tipo genérico.
class Generica<T> {
hazAlgo(x: T, y: T): T {
return x;
}
}
Como puedes ver, ese genérico lo puedes usar a lo largo del código de la clase, por ejemplo, el método hazAlgo() recibe dos genéricos y luego devuelve otro genérico.
En el momento de instanciar un objeto de esta clase, podemos indicar el tipo concreto de datos que se aplicará a ese genérico.
let instancia = new Generica<number>();
Ahora, usando los métodos de la clase se podrá inferir el tipo de datos que reciben los métodos con parámetros genéricos o el tipo de datos genérico devuelto.
let variable = instancia.hazAlgo(3, 4);
Aquí, "variable" será deducida como de tipo "number", tal como puedes apreciar en la siguiente imagen.
Solo que esto nos plantea un problema y es que en el método hazAlgo(), tal como está definido ahí, es imposible saber de qué tipo son los parámetros en la implementación del método hazAlgo(), por lo que intentar hacer operaciones con ellos puede derivar en errores en tiempo de compilación.
Así que se puede tomar una alternativa de declarar los métodos con sus tipos genéricos y realizar la implementación más adelante. Es más o menos lo que puedes apreciar en el siguiente código.
class Generica<T> {
suma: (x: T, y: T) => T;
}
let instancia = new Generica<number>();
instancia.suma = function(x, y) {
return x + y;
}
let instanciaString = new Generica<string>();
instanciaString.suma = function(x, y) {
return x + y;
}
Luego podríamos usar estos métodos para producir salida, enviando parámetros de los tipos adecuados, porque si no el compilador se quejará.
let variable = instancia.suma(3, 4);
console.log(variable);
let variableString = instanciaString.suma("Hola", " DesarrolloWeb.com");
console.log(variableString);
Añadir restricciones a los genéricos
El problema que hemos empezado a observar en el ejemplo anterior puede ser resuelto añadiendo una restricción a los genéricos.
Vamos a observar este código:
function mostrar<T>(dato: T) {
console.log(dato.length);
}
Si mi genérico recibido como parámetro fuera una cadena, o un array, podría estar seguro que en el cuerpo de la función se puede acceder a su propiedad length. Sin embargo, el genérico te puede aceptar cualquier cosa, por lo que el compilador se queja porque cualquier cosa no necesariamente tendrá la propiedad length.
recuerda que el genérico no es un tipo "any". Si fuera declarado como "any" el parámetro el compilador no se quejaría. La diferencia es que el genérico, aunque técnicamente pueda ser cualquier cosa, el compilador se debe preocupar porque todo lo que reciba, sea lo que sea, pueda realizar las operaciones que se marcan en la implementación de la función.
Una alternativa para solucionarlo es restringir el genérico por medio de una interfaz. Luego, podemos decir en la función que el genérico puede ser cualquier cosa que adopte esa interfaz.
interface conLength {
length: number;
}
function mostrar<T extends conLength>(dato: T) {
console.log(dato.length);
}
Ahora, si intento hacer algo como esto:
mostrar(3);
El compilador se quejará porque el "number" 3 no es algo que disponga de la propiedad length. "[ts] Argument of type '3' is not assignable to parameter of type 'conLength'."
Sin embargo, si le paso una cadena, no habrá ningún problema.
mostrar("test");
También podremos pasar un objeto cualquiera, con tal que tenga la propiedad length:
mostrar({ length: 3, otraCosa: 'test' });
Sin embargo, si el objeto tiene la propiedad length pero no es de tipo number, el compilador te arrojará un error.
mostrar({ length: "no es number", otraCosa: 'test' });
Restringir con otro genérico
Ya para terminar, vemos algo todavía más raro, que es restringir el tipo a partir de otro genérico.
function restringir<T extends U, U>(param1: T, param2: U): U {
return param2;
}
En este caso estás diciendo que lo que envíes como dato a "param1" debe tener, al menos, las mismas propiedades (y mismos tipos) que se encuentran en el tipo de "param2".
Por tanto, esto sería correcto:
let obj1 = {
a: 1,
b: 2
}
let obj2 = {
a: 1
}
restringir(obj1, obj2);
Pero esto otro no sería, ya que el objeto obj2 contiene propiedades que no están en obj1.
let obj1 = {
a: 1,
b: 2
}
let obj2 = {
noSeEncuentra: 1
}
restringir(obj1, obj2);
Tampoco sería válido este código, porque, a pesar de contener las mismas propiedades, los tipos de obj1 no son los mismos delos tipos de obj2.
let obj1 = {
a: 1,
b: 2
}
let obj2 = {
a: "55",
b: "4"
}
restringir(obj1, obj2);
Hasta aquí hemos visto muchas posibilidades de los genéricos en TypeScript, muchas de bastante utilidad, aunque algunas otras sin duda bastante extrañas. Esperamos que, aunque no hubieras oído hablar de tipos generics, con esta ayuda hayas despejado tus dudas.
Miguel Angel Alvarez
Fundador de DesarrolloWeb.com y la plataforma de formación online EscuelaIT. Com...