Tipos Generalizados (Generics) en TypeScript

Construir componentes con interfaces de programación consistentes y conscientes es una de las bases de la ingeniería de software, y hacer que estos componentes sean reutilizables también es una de las bases más importantes. Los componentes que pueden trabajar con los datos actuales y futuros brindarán posibilidades muy flexibles para construir grandes sistemas de software.

Los genéricos son una de las principales herramientas que ayudan a construir componentes reutilizables en lenguajes como C# y Java, ya que brindan la posibilidad de crear componentes que pueden trabajar con varios tipos de datos en lugar de uno solo. Esto permite a los usuarios confiar en estos componentes y utilizar sus propios tipos.

Tabla de contenidos


Ejemplo más simple de tipos generalizados(generics)

Para empezar, veamos el ejemplo más simple en el mundo de los tipos generalizados: la función de identidad. La función identidad es una función que devuelve los valores que se le pasan. Se le puede comparar con el eco. Sin usar tipos generalizados, tendríamos que darle a la función de identidad un cierto tipo:

function identity(arg: number): number {
    return arg;
}

O la función de identidad se puede describir como any:

function identity(arg: any): any {
    return arg;
}

Aunque el uso de type está any generalizado porque hará que la función acepte cualquier tipo sea cual sea el valor de arg, perdemos información sobre el tipo que devuelve la función. Por ejemplo, si pasamos un número, la única información que tendremos es que la función devolverá cualquier tipo que sea.

En cambio, necesitamos una forma de capturar el tipo del valor del parámetro pasado de tal manera que podamos saber qué devolverá la función. Aquí usaremos una variable de tipo, que es una variable especial que opera en tipos en lugar de valores:

function identity<T>(arg: T): T {
    return arg;
}

Ahora hemos agregado una variable de tipo nombrada T a la función de identidad. Esta variable T nos permite capturar el tipo que el usuario pasa ( number por ejemplo ), para que podamos usar esta información después. Aquí usamos la variable T nuevamente como un tipo de devolución. Ahora podemos ver que se usa el mismo tipo tanto con el operando como con el tipo de retorno. Esto permite que la información de tipo pase de un lado de la entrada de la función al otro lado.

Decimos que esta versión de la función identity es genérica, porque funciona con muchos tipos. A diferencia del uso de type any, esta función trabaja con la misma precisión que la función identity que usa números para su parámetro y tipo de retorno, porque no pierde ninguna información. Después de escribir la función de identidad generalizada, ahora podemos llamarla de una de dos maneras. El primer método es pasar todos los parámetros, incluido el parámetro de tipo, a la función:

// El tipo de la variable será de tipo 
// 'string'
let output = identity<string>("myString");

Aquí establecemos explícitamente el valor del parámetro de tipo T como string uno de los valores pasados ​​a la llamada de función, denotando el parámetro de tipo encerrándolo entre paréntesis ( ) en lugar de ‎<>.

El segundo método es quizás el más famoso y usa la inferencia de argumento de tipo, lo que significa que queremos que el compilador establezca el valor de T demodo que se base en el tipo de parámetro que se pasa automáticamente:

// el tipo de la variable será de tipo 
// 'string'
let output = identity("myString");

Tenga en cuenta que no necesitábamos pasar el tipo explícitamente usando corchetes angulares ( <>). El compilador solo miró el valor "myString" y estableció valor de T para representar su tipo. Aunque la inferencia de parámetros de tipo es una herramienta útil para mantener su código corto y más legible, es posible que deba pasar valores para los parámetros de tipo como hicimos en el ejemplo anterior si el compilador no puede inferir el tipo, lo que puede suceder en situaciones más complejas.

Trabajar con variables de tipo generics

Cuando trabaje con tipos generalizados, puede notar que cuando crea funciones como una función identity, compilador descartará que los operandos de tipos generalizados deban usarse correctamente dentro del cuerpo de la función. Es decir, usted trata estas transacciones como si fueran de cualquier tipo. Por ejemplo, tomemos la función identity del ejemplo anterior:

function identity<T>(arg: T): T {
    return arg;
}

Pero, ¿y si quisiéramos imprimir la longitud del valor del parámetro argen la pantalla en cada invocación? Podría considerar hacerlo de la siguiente manera:

function loggingIdentity<T>(arg: T): T {
    // Falso, T no tiene una función llamada .length 
    console.log(arg.length);
    return arg;
}

Cuando hacemos esto, el compilador arroja un error que nos dice que estamos usando el elemento .length from arg, pero no hemos especificado en ninguna parte que el operando arg sea propietario este elemento. Recuerde que estas variables de tipo se utilizan para todos los tipos de cualquier tipo, por lo que se puede llamar a esta función pasando un número ( number) , que no tiene un elemento llamado .length.

Supongamos que queremos que esta función funcione con matrices de tipo T en lugar de hacerlo T directamente. Y como estamos trabajando con arreglos, el elemento .length estará disponible. Podemos describir esto como la creación de matrices de otros tipos:

function loggingIdentity<T>(arg: T[]): T[] {
    console.log(arg.length);  // Ya no hay errores porque las matrices tienen este elemento 
    return arg;
}

El tipo de una función loggingIdentity se puede describir de la siguiente manera: «La función toma un argumento de tipo loggingIdentity como su nombre T, y un operando con nombre arg es una matriz de elementos de T y devuelve una matriz de elementos de tipo T«. Si le pasamos una matriz de números, obtenemos una matriz de números como salida, y está configurado T para ser del tipo number

Esto nos permite usar la variable de tipo generalizado T como parte de los tipos con los que estamos tratando en lugar del tipo completo, lo que nos brinda más flexibilidad. Alternativamente, podemos escribir el ejemplo anterior de la siguiente manera:

function loggingIdentity<T>(arg: Array<T>): Array<T> {
    console.log(arg.length);  // Ya no hay errores porque las matrices tienen este elemento 
    return arg;
}

Este método puede resultarle familiar si está utilizando uno de los otros lenguajes de programación que utilizan tipos generalizados similares. En la siguiente sección, cubriremos cómo crear sus propios tipos generalizados como ‎Array‎<‎T‎>‎.

Tipos generales

En las secciones anteriores, hemos creado identificadores generalizados que funcionan con varios tipos. En esta sección, aprenderemos sobre los tipos de funciones y cómo crear interfaces genéricas.

El tipo de funciones generalizadas es el mismo que para los tipos de funciones no generalizadas, con los parámetros de los tipos proporcionados primero de manera similar a las funciones de declaración:

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <T>(arg: T) => T = identity;

También podríamos haber usado un nombre diferente para el operador de tipo generalizado en el tipo, lo cual es correcto siempre que la cantidad de variables de tipo y cómo se usan sean consistentes:

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <U>(arg: U) => U = identity;

También podemos escribir el tipo generalizado como una firma de llamada de un tipo de objeto literal:

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: {<T>(arg: T): T} = identity;

Lo que nos lleva a escribir nuestra primera interfaz genérica. Tomemos el valor literal del objeto en el ejemplo anterior y pasémoslo a una interfaz como esta:

interface GenericIdentityFn {
    <T>(arg: T): T;
}

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: GenericIdentityFn = identity;

Para un ejemplo similar, podríamos querer mover el parámetro generalizado para que sea un parámetro para toda la interfaz. Esto nos permite saber qué tipo o tipos son más generales (por ejemplo, tipo ‎Dictionary‎<‎string>‎ en lugar de Dictionary solo). Esto hace que el parámetro de tipo sea visible para todos los demás elementos de la interfaz.

interface GenericIdentityFn<T> {
    (arg: T): T;
}

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

Tenga en cuenta que el ejemplo ha cambiado, por lo que en lugar de describir una función generalizada, ahora tenemos una firma de función no genérica que forma parte de un tipo generalizado. Cuando usamos GenericIdentityFn, ahora necesitamos especificar el parámetro de tipo como debería (pasando el tipo aquí number), lo que determina qué usará la firma de llamada implícita. 

Comprender cuándo el parámetro de tipo debe pasarse directamente en la firma de llamada y cuándo debe colocarse en la interfaz ayudará a describir qué partes del tipo deben generalizarse. Además de las interfaces generalizadas, también podemos crear clases generalizadas. Tenga en cuenta que no es posible crear enumeraciones o espacios de nombres globalizados.

Clases Generalizadas

Las clases generalizadas tienen una forma similar a las interfaces generalizadas. Las clases generalizadas tienen una lista de parámetros de tipo genérico que se incluye entre corchetes angulares ( <>) después del nombre de la clase:

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

Este es un uso literal de la clase GenericNumber, ya que su nombre se refiere a un número generalizado, pero es posible que hayas notado que podríamos haber usado un tipo diferente a number. Por ejemplo, podríamos usar el tipo string, o incluso objetos más complejos:

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function(x, y) { return x + y; };

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

Al igual que las interfaces, usar el parámetro de tipo con la misma clase nos permite asegurarnos de que todas las propiedades de la clase funcionen con el mismo tipo. Como explicamos anteriormente en la página de clases, un tipo de clase tiene dos lados: el lado estático y el lado de instancia. Las clases generalizadas solo se generalizan en el lado de instancia y no en el lado estático, por lo que cuando se trabaja con clases, los elementos estáticos no pueden usar el parámetro de tipo de clase.

Restricciones generales

Es posible que haya notado en un ejemplo anterior que a veces necesitamos escribir una función generalizada que opera en una matriz de tipos donde sabe algo sobre las capacidades de ese conjunto de tipos. En nuestro ejemplo loggingIdentity, queríamos acceder a la propiedad .length del objeto arg, pero el compilador no pudo probar que todos los tipos tienen una propiedad llamada .length, por lo que nos advierte que no podemos asumir que:

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // Falso, el tipo no tiene la propiedad 
    return arg;
}

En lugar de trabajar con todos los tipos de cualquier tipo, queremos restringir solo esta función .length. Mientras la especie tenga este elemento, estará permitido, pero tener al menos este elemento es imprescindible. Para hacer esto, debemos especificar este requisito como una restricción en el tipo T.

Para hacer esto, crearemos una interfaz que describa esta restricción. La siguiente interfaz tiene una sola propiedad llamada.lengthy luego usamos esta interfaz y la palabra clave extends para indicar esta limitación:

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
// Ahora sabemos que el tipo tiene la propiedad 
// .length por lo que ya no hay errores 
    console.log(arg.length);
    return arg;
}

La función generalizada no funcionará con todos los tipos después de esto porque está restringida:

loggingIdentity(3);  // Falso, el número no tiene la propiedad en la restricción

Así, la función solo funcionará con valores que satisfagan exclusivamente todos los requisitos:

loggingIdentity({length: 10, value: 3});

Uso de restricciones generalizadas en operandos de tipos

Es posible declarar un parámetro de tipo que está vinculado por un parámetro de otro tipo. Por ejemplo, aquí queremos obtener una propiedad de un objeto dado su nombre. Y queremos asegurarnos de que no estamos tratando de obtener una propiedad que no existe en el objeto obj, por lo que pondremos una restricción entre ambos tipos:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // permitido
// Falso, el valor de un parámetro de tipo 
// 'm' no se puede establecer en 
// 'a' | 'b' | 'c' | 'd' 
getProperty(x, "m");

Uso de tipos de clase en tipos generalizados

Al construir fábricas en TypeScript usando tipos generalizados, es necesario hacer referencia a los tipos de clases a través de sus funciones de construcción. por ejemplo:

function create<T>(c: {new(): T; }): T {
    return new c();
}

Un ejemplo más complejo usa la propiedad prototipo para inferir y restringir las relaciones entre el constructor y el lado de la instancia de los tipos de clase:

class BeeKeeper {
    hasMask: boolean;
}

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Bee extends Animal {
    keeper: BeeKeeper;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // expresión adecuada 
createInstance(Bee).keeper.hasMask;  // expresión adecuada

Recursos del Artículo


Deja un comentario