Desarrollo de Sistemas Informáticos - Grado en Ingeniería Informática - ULL

View project on GitHub

Arrays, tuplas y enumerados

Hasta ahora hemos visto como utilizar tipos de datos básicos en TypeScript. En este apartado, veremos tipos un poco más complejos que permiten llevar a cabo agrupaciones de otros elementos de diferentes tipos: arrays, tuplas y enumerados.

Para comenzar, puede partir de un proyecto ya existente o configurar uno nuevo. Al mismo tiempo, el fichero de configuración del compilador de TypeScript debe contener lo siguiente:

{
  "compilerOptions": {
    "rootDir": "./src" ,
    "outDir": "./dist",
    "module": "commonjs",
    "target": "es2024",
    "declaration": true,
  }
}

Por último, el fichero index.ts deberá contener lo siguiente:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let mySum = add(8, 7);
printNumber(mySum);

Arrays

Los arrays en JavaScript pueden contener elementos de cualquier tipo y, además, son de tamaño variable. TypeScript no restringe el uso de los arrays respecto a su tamaño variable, pero si que lo hace respecto a los tipos de datos de los elementos que contienen, gracias al uso de anotaciones de tipo explícitas:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let myAdditions: number[] = [add(8, 9), add(1, 7), add(20, 43)];
printNumber(myAdditions[0]);
printNumber(myAdditions[1]);
printNumber(myAdditions[2]);

En el ejemplo anterior, el array myAdditions se ha anotado explícitamente con el tipo number[]. Otra opción sería anotarlo como Array<number> (conoceremos más en profundidad esta sintaxis cuando hablemos sobre genéricos). Ambas anotaciones indican que el array solo puede contener elementos de tipo number. De hecho, trate de compilar el siguiente ejemplo:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let myAdditions: number[] = [add(8, 9), add(1, 7), add(20, 43), "17"];
printNumber(myAdditions[0]);
printNumber(myAdditions[1]);
printNumber(myAdditions[2]);

Se puede observar como el compilador de TypeScript informa del siguiente error, dado que estamos intentando asignar la cadena de caracteres "17", que es de tipo string, como un elemento de un array que solo puede contener elementos de tipo number:

src/index.ts:9:65 - error TS2322: Type 'string' is not assignable to type 'number'.

9 let myAdditions: number[] = [add(8, 9), add(1, 7), add(20, 43), "17"];
                                                                  ~~~~


Found 1 error in src/index.ts:9

Lo anterior podría solucionarse definiendo el array como de tipo any[]. Ya se sabe que el uso de del tipo any debería ser lo más reducido posible, dado que ganamos en flexibilidad, pero perdemos control sobre los posibles errores que podrían aparecer en tiempo de ejecución, que es lo que suele suceder al utilizar JavaScript directamente, como ya hemos mencionado con anterioridad en repetidas ocasiones.

Una opción más acertada, sería utilizar un array cuyo tipo fuera la unión de tipos string | number. No obstante, también tendríamos que utilizar un guardián de tipos para poder invocar a la función printNumber, dado que dicha función debe invocarse con un argumento de tipo number y no de tipo string | number:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let myAdditions: (number | string)[] =
  [add(8, 9), add(1, 7), add(20, 43), "17"];

myAdditions.forEach((item: number | string) => {
  if (typeof item === "number") {
    printNumber(item);
  }
});

Se puede observar como el guardián de tipos ha sido incluido dentro del cuerpo de una sentencia forEach, el cual permite saber si el elemento item por el que va iterando el bucle y que también ha sido anotado con el tipo number | string, es de tipo number. Si es así, se invoca a la función printNumber con dicho elemento. En caso contrario, ignora el elemento, que solo podrá ser de tipo string.

En ejemplos anteriores hemos anotado el array myAdditions de manera explícita, pero el compilador de TypeScript es capaz de inferir el tipo de un array llevando a cabo un análisis de los tipos de sus elementos. En el siguiente ejemplo, se ha eliminado la anotación (number | string)[] del array myAdditions, así como la anotación de item y, sin embargo, el programa sigue funcionando correctamente:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let myAdditions = [add(8, 9), add(1, 7), add(20, 43), "17"];

myAdditions.forEach((item) => {
  if (typeof item === "number") {
    printNumber(item);
  }
});

De hecho, echando un vistazo al fichero dist/index.d.ts, una vez se ha recompilado el programa, se observará lo siguiente:

declare function add(firstNum: number, secondNum: number): number;
declare function printNumber(myNum: number): void;
declare let myAdditions: (string | number)[];

Especial cuidado hay que tener a la hora de declarar arrays vacíos, sobre todo en el caso de que no se usen anotaciones de tipo explícitas para los mismos:

function add(firstNum: number, secondNum: number): number {
  return firstNum + secondNum;
}

function printNumber(myNum: number): void {
  console.log(`Number = ${myNum}`);
}

let myAdditions = [add(8, 9), add(1, 7), add(20, 43), "17"];

let myEmptyArray = [];
myEmptyArray.push(17);
myEmptyArray.push("17");
myEmptyArray.push(true);


myAdditions.forEach((item) => {
  if (typeof item === "number") {
    printNumber(item);
  }
});

El compilador no emite ningún mensaje de error con el ejemplo anterior. Si se observa el fichero dist/index.d.ts de nuevo, se podrá observar que el compilador de TypeScript, al no contar con suficiente información durante la definición del array myEmptyArray, ha inferido que su tipo es any[], lo cual permite introducir cualquier tipo de valor en el mismo posteriormente:

declare function add(firstNum: number, secondNum: number): number;
declare function printNumber(myNum: number): void;
declare let myAdditions: (string | number)[];
declare let myEmptyArray: any[];

Debido a lo anterior, es una buena práctica definir los arrays vacíos con una anotación de tipos explícita que nos ahorre potenciales errores en el futuro.

Continuando con el ejemplo anterior, si habilitamos la opción strictNullChecks del compilador de TypeScript e intentamos recompilar, el compilador nos informará de los siguientes errores:

src/index.ts:12:19 - error TS2345: Argument of type '17' is not assignable to parameter of type 'never'.

12 myEmptyArray.push(17);
                     ~~

src/index.ts:13:19 - error TS2345: Argument of type '"17"' is not assignable to parameter of type 'never'.

13 myEmptyArray.push("17");
                     ~~~~

src/index.ts:14:19 - error TS2345: Argument of type 'true' is not assignable to parameter of type 'never'.

14 myEmptyArray.push(true);
                     ~~~~


Found 3 errors in the same file, starting at: src/index.ts:12

Esta opción del compilador, tal y como hemos visto con anterioridad, restringe el uso de null y undefined y, además, evita que el compilador infiera el tipo de un array vacío como any[]. En su lugar, lo que hace es inferir el tipo del array como never[], lo que hace que no se pueda añadir ningún elemento al array, a no ser que el array se anote explícitamente o se inicialice con, al menos, algún elemento.

Tuplas

Las tuplas son un tipo de datos proporcionado por el compilador de TypeScript que, en tiempo de ejecución, son gestionados por JavaScript como arrays regulares. En concreto, son arrays de longitud fija donde cada elemento puede ser de diferente tipo.

let bankActivities = [["Trail Shop", 23.45], ["Coffee Shop", 15.43]];

bankActivities.forEach((activity) => {
  console.log(`I spent ${activity[1]} euros at ${activity[0]}`);
});

En el ejemplo anterior puede observarse como hemos declarado un array bidimensional bankActivities, que contiene una lista de pares de tipo string y number. No obstante, el tipo de datos inferido para dicho array bidimensional es (string | number)[][]. Debido a lo anterior, podemos hacer operaciones como las siguientes:

let bankActivities = [["Trail Shop", 23.45], ["Coffee Shop", 15.43]];

bankActivities.forEach((activity) => {
  console.log(`I spent ${activity[1]} euros at ${activity[0]}`);
});

bankActivities[0] = ["Supermarket"];
bankActivities[1][1] = "15.43";
bankActivities.push(["Pub", "Tons of beers", 15, 16]);

En la primera asignación, al primer elemento del array bankActivities, que a su vez es un array formado por un primer elemento de tipo string y un segundo elemento de tipo number, se le ha podido asignar un array de un único elemento cuyo tipo es string. En la segunda asignación, el valor numérico del segundo elemento de bankActivities ha sido sustituido por una cadena de caracteres. Mediante el método push, hemos añadido un nuevo elemento a bankActivities que se trata de un array de cuatro elementos donde los dos primeros son cadenas de caracteres y los dos últimos son números. El compilador de TypeScript no emite ningún tipo de error respecto a las asignaciones anteriores dado que ha inferido que el tipo de datos de bankActivities es un array cuyos elementos pueden ser arrays de la unión de tipos (string|number).

La lógica que queremos implementar no debería permitir operaciones como las anteriores, dado que, lo que realmente deseamos, es que el array bankActivities contenga arrays de dos elementos, ni un elemento más, ni un elemento menos, donde, siempre, el primer elemento sea un string y el segundo elemento sea un number. Y es ahí donde nos viene como anillo al dedo el uso de las tuplas:

let bankActivities: [string, number][] =
  [["Trail Shop", 23.45], ["Coffee Shop", 15.43]];

bankActivities.forEach((activity) => {
  console.log(`I spent ${activity[1]} euros at ${activity[0]}`);
});

bankActivities[0] = ["Supermarket"];
bankActivities[1][1] = "15.43";
bankActivities.push(["Pub", "Tons of beers", 15, 16]);

Ante el ejemplo anterior, el compilador de TypeScript informará de los siguientes errores:

src/index.ts:8:1 - error TS2322: Type '[string]' is not assignable to type '[string, number]'.
  Source has 1 element(s) but target requires 2.

8 bankActivities[0] = ["Supermarket"];
  ~~~~~~~~~~~~~~~~~

src/index.ts:9:1 - error TS2322: Type 'string' is not assignable to type 'number'.

9 bankActivities[1][1] = "15.43";
  ~~~~~~~~~~~~~~~~~~~~

src/index.ts:10:29 - error TS2322: Type 'string' is not assignable to type 'number'.

10 bankActivities.push(["Pub", "Tons of beers", 15, 16]);
                               ~~~~~~~~~~~~~~~


Found 3 errors in the same file, starting at: src/index.ts:8

Ahora, bankActivities se ha anotado explícitamente con el tipo [string, number][], es decir, se trata de un array de tuplas, donde una tupla se ha anotado mediante el tipo [string, number]. Es por ello que bankActivities solo podrá contener, como elementos, tuplas donde el primer elemento de cada tupla será una cadena de caracteres y el segundo será un valor numérico.

Es importante tener en cuenta que, siempre que queramos utilizar tuplas en nuestro código, deberemos llevar a cabo una anotación de tipos explícita como la del ejemplo anterior, dado que TypeScript no será capaz de inferir dicho tipo de manera implícita.

El código fuente del ejemplo anterior sería más legible y limpio si utilizáramos un alias de tipo para las tuplas:

type BankActivity = [string, number];

let bankActivities: BankActivity[] =
  [["Trail Shop", 23.45], ["Coffee Shop", 15.43]];

bankActivities.forEach((activity) => {
  console.log(`I spent ${activity[1]} euros at ${activity[0]}`);
});

Las tuplas, al igual que los arrays, pueden deserializarse:

type BankActivity = [string, number];

let bankActivities: BankActivity[] =
  [["Trail Shop", 23.45], ["Coffee Shop", 15.43]];

bankActivities.forEach((activity) => {
  const [activityName, activityPrice] = activity;
  console.log(`I spent ${activityPrice} euros at ${activityName}`);
});

Tal y como puede observarse, las constantes activityName y activityPrice recogen, dentro del bucle forEach, los valores de cada tupla, iterada en activity, de manera independiente.

Luego, una tupla puede contener elementos opcionales, todos ubicados al final de la misma, y para los cuales hay que utilizar el caracter ? en la definición de su tipo. La tupla seguirá teniendo una longitud fija y los elementos opcionales tomarán el valor undefined en caso de no definirlos:

type BankActivity = [string, number, string?];

let bankActivities: BankActivity[] = [
  ["Trail Shop", 23.45, "Madrid"],
  ["Coffee Shop", 15.43],
];

bankActivities.forEach((activity) => {
  const [activityName, activityPrice, activityLocation] = activity;
  console.log(
    `I spent ${activityPrice} euros at ${activityName} located at ${activityLocation}`,
  );
});

Se puede observar como ahora las tuplas de tipo BankActivity tienen un tercer elemento opcional de tipo string, utilizado para indicar la ubicación del movimiento bancario. La primera tupla del array bankActivities define el tercer elemento, pero la segunda no. Lo anterior no hace que el compilador de TypeScript emita algún error. Al mostrar la información de cada tupla, se puede observar lo siguiente:

I spent 23.45 euros at Trail Shop located at Madrid
I spent 15.43 euros at Coffee Shop located at undefined

Por último, una tupla puede contener un único elemento rest, ubicado al final de la misma, después de los elementos opcionales, y que puede utilizarse para definir múltiples valores de un tipo concreto. Lo anterior hace que la tupla pase a tener una longitud variable, perdiendo un poco el sentido para el que han sido definidas:

type Expense = [string, ...number[]];

let expenses: Expense[] = [
  ["Tenerife", 15.43],
  ["Madrid", 23.45, 10.5, 74.8],
  ["Barcelona"],
];

expenses.forEach((expense) => {
  const [expenseLocation, ...amounts] = expense;
  const totalAmount = amounts.reduce((total, value) => total + value, 0);

  console.log(`I spent ${totalAmount} at ${expenseLocation}`);
});

Obsérvese en el ejemplo anterior como se ha declarado un tipo Expense para una tupla cuyo primer elemento es de tipo string y los restantes son de tipo number, gracias al uso de la sintaxis ...number[] para definir el elemento rest. Al declarar el array expenses, la primera tupla tiene tres elementos (uno numérico), la segunda tiene cuatro elementos (tres numéricos) y la tercera tiene un único elemento (sin elementos numéricos). Dentro del bucle forEach, cada tupla apuntada por expense, se deserializa gracias a la siguiente sentencia:

const [expenseLocation, ...amounts] = expense;

Lo interesante de dicha sentencia es como se deserializa el elemento rest como un array de tipo number[] que queda apuntado por la constante amounts. Luego, se invoca el método reduce para sumar todos los elementos del array amounts. Al ejecutar el ejemplo anterior se obtiene lo siguiente por la consola:

I spent 15.43 at Tenerife
I spent 108.75 at Madrid
I spent 0 at Barcelona

Enumerados

Otro tipo de datos con el que TypeScript amplía a JavaScript es enum. Permite definir un conjunto de etiquetas, cada una de ellas con una constante numérica asociada. Por defecto, el compilador de TypeScript enumera automáticamente cada etiqueta, comenzando por el valor cero. El uso de enumerados permite desarrollar un código mucho más limpio. A continuación, se muestra un ejemplo de declaración y uso de un enumerado:

enum ActivityTypes {SUPERMARKET, MORTGAGE, TRANSPORT};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the supermarket`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my mortgage`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in transport`);
      break;
  }
});

Hemos declarado un enumerado ActivityTypes con un conjunto de etiquetas que podemos utilizar en nuestro código fuente. La primera etiqueta tiene asociada, por defecto, la constante numérica cero. Las siguientes etiquetas tienen asociadas constantes numéricas con valores enteros consecutivos al cero. Esto puede observarse en el fichero dist/index.d.ts generado:

declare enum ActivityTypes {
    SUPERMARKET = 0,
    MORTGAGE = 1,
    TRANSPORT = 2
}
type BankActivity = [ActivityTypes, number];
declare let bankActivities: BankActivity[];

También es curiosa la manera en la que la funcionalidad proporcionada por enum es implementada en JavaScript. Observe el contenido del fichero dist/index.js:

var ActivityTypes;
(function (ActivityTypes) {
    ActivityTypes[ActivityTypes["SUPERMARKET"] = 0] = "SUPERMARKET";
    ActivityTypes[ActivityTypes["MORTGAGE"] = 1] = "MORTGAGE";
    ActivityTypes[ActivityTypes["TRANSPORT"] = 2] = "TRANSPORT";
})(ActivityTypes || (ActivityTypes = {}));
;
let bankActivities = [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];
bankActivities.forEach((activity) => {
    switch (activity[0]) {
        case ActivityTypes.SUPERMARKET:
            console.log(`I spent ${activity[1]} euros in the supermarket`);
            break;
        case ActivityTypes.MORTGAGE:
            console.log(`I spent ${activity[1]} euros for my mortgage`);
            break;
        case ActivityTypes.TRANSPORT:
            console.log(`I spent ${activity[1]} euros in transport`);
            break;
    }
});

Podría darse el caso en el que no queramos que las constantes numéricas asociadas a las etiquetas del enumerado comenzaran por cero. Para ello, podemos asignar un valor explícitamente a la primera etiqueta. El resto de etiquetas tomarán valores consecutivos:

enum ActivityTypes {SUPERMARKET = 7, MORTGAGE, TRANSPORT};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the supermarket`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my mortgage`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in transport`);
      break;
  }
});

Ahora el fichero dist/index.d.ts contendrá lo siguiente:

declare enum ActivityTypes {
    SUPERMARKET = 7,
    MORTGAGE = 8,
    TRANSPORT = 9
}
type BankActivity = [ActivityTypes, number];
declare let bankActivities: BankActivity[];

También, podríamos asignar un valor arbitrario a cada etiqueta del enumerado:

enum ActivityTypes {SUPERMARKET = 26, MORTGAGE = 25, TRANSPORT = 24};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the supermarket`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my mortgage`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in transport`);
      break;
  }
});

Con lo anterior debemos tener cuidado, dado que podríamos llegar a tener valores numéricos duplicados asignados a las etiquetas del enumerado en el caso de que inicialicemos algunas etiquetas con valores arbitrarios y permitamos que el compilador de TypeScript asigne automáticamente otros valores. Para asignar valores de manera automática, el compilador solo se fija en el valor numérico inmediatamente anterior, ya haya sido asignado automáticamente o manualmente:

enum ActivityTypes {SUPERMARKET = 7, MORTGAGE = 6, TRANSPORT};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the supermarket`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my mortgage`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in transport`);
      break;
  }
});

En el ejemplo anterior, a las etiquetas SUPERMARKET y TRANSPORT se les ha asignado la misma constante numérica siete. El resultado de la ejecución del programa anterior es el siguiente:

I spent 23.45 euros in the supermarket
I spent 15.43 euros in the supermarket

Además de asignar constantes numéricas a las etiquetas de un enumerado, también se pueden asignar cadenas de caracteres, e incluso, combinaciones de cadenas de caracteres y números (esto no es muy común), tal y como se puede observar en el siguiente ejemplo:

enum ActivityTypes {SUPERMARKET = "SPR", MORTGAGE = 6, TRANSPORT};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the supermarket`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my mortgage`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in transport`);
      break;
  }
});

Una ventaja de utilizar cadenas de caracteres como valores asociados a las etiquetas de un enumerado es que, por ejemplo, el proceso de depuración se hace más legible que utilizando valores numéricos.

Por último, mencionar que también se pueden utilizar las propias etiquetas de un enumerado como cadenas de caracteres, siempre y cuando dichas etiquetas tengan asociadas constantes numéricas:

enum ActivityTypes {SUPERMARKET, MORTGAGE, TRANSPORT};

type BankActivity = [ActivityTypes, number];

let bankActivities: BankActivity[] =
  [[ActivityTypes.SUPERMARKET, 23.45], [ActivityTypes.TRANSPORT, 15.43]];

bankActivities.forEach((activity) => {
  switch (activity[0]) {
    case ActivityTypes.SUPERMARKET:
      console.log(`I spent ${activity[1]} euros in the ${ActivityTypes[activity[0]]}`);
      break;
    case ActivityTypes.MORTGAGE:
      console.log(`I spent ${activity[1]} euros for my ${ActivityTypes[activity[0]]}`);
      break;
    case ActivityTypes.TRANSPORT:
      console.log(`I spent ${activity[1]} euros in ${ActivityTypes[activity[0]]}`);
      break;
  }
});