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

View project on GitHub

Objetos, clases e interfaces

Este capítulo pretende introducir el uso y funcionalidades más relevantes de TypeScript relacionados con los objetos, clases e interfaces.

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 deberá contener lo siguiente:

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

Por último, supongamos que partimos del siguiente ejemplo de código fuente contenido en el fichero src/index.ts:

let rectangle = {
  name: "Rectangle",
  sides: 4,
};

let triangle = {
  name: "Triangle",
  sides: 3,
};

let hexagon = {
  name: "Hexagon",
  sides: 6,
};

const figures = [rectangle, triangle, hexagon];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name} and I have ${figure["sides"]} sides`);
});

Objetos

El ejemplo anterior muestra la creación de tres objetos diferentes, los cuales tienen una propiedad name y una propiedad sides. Luego, en el bucle, se pueden observar dos maneras diferentes de acceder a una propiedad de un objeto, o bien haciendo uso de la notación basada en punto (figure.name), o bien de la notación basada en corchetes (figure["sides"]).

Los objetos en JavaScript representan un conjunto de propiedades y sus respectivos valores. Pueden crearse mediante sintaxis literal, constructores o clases y, una vez creados, se pueden modificar añadiendo y eliminando propiedades. Para gestionar el tipo de un objeto, TypeScript se basa en la forma de dicho objeto, es decir, en la combinación particular de los nombres de las propiedades y sus respectivos tipos.

Para ilustrar lo anterior, trate de compilar el ejemplo de código fuente de más arriba, el cual declara objetos utilizando sintaxis literal, y observe el contenido del fichero dist/index.d.ts:

declare let rectangle: {
    name: string;
    sides: number;
};
declare let triangle: {
    name: string;
    sides: number;
};
declare let hexagon: {
    name: string;
    sides: number;
};
declare const figures: {
    name: string;
    sides: number;
}[];

Se puede observar como el compilador de TypeScript ha inferido de manera implícita la forma del tipo de cada uno de los objetos declarados. Todos los objetos cuentan con una forma consistente en una propiedad name, cuyo tipo es un string, y una propiedad sides, cuyo tipo es number. Además, gracias a que ha inferido la forma del tipo de cada objeto declarado, también ha sido capaz de inferir la forma del tipo del array figures.

El hecho de que el compilador de TypeScript haya inferido de manera correcta la forma de los objetos es de gran utilidad, dado que permite detectar ciertos errores. Por ejemplo, añadamos una nueva figura a nuestro ejemplo anterior:

let rectangle = {
  name: "Rectangle",
  sides: 4,
};

let triangle = {
  name: "Triangle",
  sides: 3,
};

let hexagon = {
  name: "Hexagon",
  sides: 6,
};

let pentagon = {
  name: "Pentagon",
};

const figures = [rectangle, triangle, hexagon, pentagon];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name} and I have ${figure.sides} sides`);
});

Al tratar de compilar el código anterior, el compilador emite el siguiente error:

src/index.ts:23:58 - error TS2339: Property 'sides' does not exist on type '{ name: string; }'.

23   console.log(`I am a ${figure.name} and I have ${figure.sides} sides`);
                                                            ~~~~~


Found 1 error in src/index.ts:23

El contenido del fichero dist/index.d.ts contiene lo siguiente ahora:

declare let rectangle: {
    name: string;
    sides: number;
};
declare let triangle: {
    name: string;
    sides: number;
};
declare let hexagon: {
    name: string;
    sides: number;
};
declare let pentagon: {
    name: string;
};
declare const figures: {
    name: string;
}[];

Se puede observar como ha cambiado el tipo inferido para el array figures. A la hora de asignarle objetos con formas diferentes, el compilador infiere un tipo que contiene las propiedades comunes a todos los objetos asignados, dado que son las únicas que no implicarían la aparición de posibles errores. La única propiedad común a todos los objetos del ejemplo anterior es name que es de tipo string. Es por ello que el compilador emite un error a la hora de intentar acceder a la propiedad sides de los objetos del array dentro del bucle forEach.

Anotaciones de tipo en objetos

Cuando se utiliza la sintaxis literal para definir un objeto, hemos visto como el compilador de TypeScript es capaz de inferir la forma del tipo de un objeto gracias a que infiere el tipo de cada una de sus propiedades basándose en los valores literales asignados. No obstante, también se pueden llevar a cabo anotaciones de tipo explícitas para definir la forma del tipo de un objeto:

let rectangle = {
  name: "Rectangle",
  sides: 4,
};

let triangle = {
  name: "Triangle",
  sides: 3,
};

let hexagon = {
  name: "Hexagon",
  sides: 6,
};

let pentagon = {
  name: "Pentagon",
};

const figures: { name: string; sides: number }[] = [
  rectangle,
  triangle,
  hexagon,
  pentagon,
];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name} and I have ${figure.sides} sides`);
});

En el ejemplo anterior, se puede observar como el tipo del array figures se ha anotado explícitamente, lo que hace que el compilador de TypeScript informe del siguiente error:

src/index.ts:24:3 - error TS2741: Property 'sides' is missing in type '{ name: string; }' but required in type '{ name: string; sides: number; }'.

24   pentagon,
     ~~~~~~~~

  src/index.ts:20:32
    20 const figures: { name: string; sides: number }[] = [
                                      ~~~~~
    'sides' is declared here.


Found 1 error in src/index.ts:24

El error anterior se debe a que estamos intentando asignar el objeto pentagon, que no cuenta con la propiedad sides, a un array que ha sido anotado explícitamente para que todos los objetos que contenga incluyan dicha propiedad.

Un objeto debe definir todas las propiedades especificadas en la forma del tipo correspondiente. El compilador no emitirá ningún error, incluso, en el caso de que un objeto incluya propiedades adicionales que no se hayan especificado en la forma de su tipo.

En el siguiente ejemplo se puede observar como el objeto pentagon cuenta con una propiedad adicional color. Sin embargo, el compilador de TypeScript no emite ningún error, dado que da por válida la forma del tipo del objeto pentagon al incluir las propiedades name y sides, de tipo string y number, respectivamente, ambas necesarias para poder asignar dicho objeto al array figures:

let rectangle = {
  name: "Rectangle",
  sides: 4,
};

let triangle = {
  name: "Triangle",
  sides: 3,
};

let hexagon = {
  name: "Hexagon",
  sides: 6,
};

let pentagon = {
  name: "Pentagon",
  sides: 5,
  color: "blue",
};

const figures: { name: string; sides: number }[] = [
  rectangle,
  triangle,
  hexagon,
  pentagon,
];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name} and I have ${figure.sides} sides`);
});

Propiedades opcionales

A la hora de definir la forma del tipo de un objeto, se puede indicar que una propiedad concreta sea opcional, lo cual añade cierta flexibilidad a la hora de trabajar con objetos cuyos tipos tengan formas diferentes:

let rectangle = {
  name: "Rectangle",
  sides: 4,
};

let triangle = {
  name: "Triangle",
  sides: 3,
};

let hexagon = {
  name: "Hexagon",
  sides: 6,
};

let pentagon = {
  name: "Pentagon",
  sides: 5,
  color: "blue",
};

const figures: { name: string; sides: number; color?: string }[] = [
  rectangle,
  triangle,
  hexagon,
  pentagon,
];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and I am ${figure.color}`,
  );
});

Ahora, la forma del tipo del array figures define una propiedad opcional denominada color. A la hora de imprimir la información de cada figura por la consola, se puede observar como se muestra el valor undefined para aquellos objetos que no tienen definida la propiedad color:

I am a Rectangle, I have 4 sides and I am undefined
I am a Triangle, I have 3 sides and I am undefined
I am a Hexagon, I have 6 sides and I am undefined
I am a Pentagon, I have 5 sides and I am blue

Definición de métodos en objetos

Un objeto puede contener métodos, además de propiedades. El siguiente ejemplo ilustra la definición de un método en la forma del tipo de un objeto:

let rectangle = {
  name: "Rectangle",
  sides: 4,
  area: (base: number, height: number) => base * height,
};

let triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
};

let circle = {
  name: "Circle",
  area: (radius: number) => Math.PI * Math.pow(radius, 2),
};

const figures: {
  name: string;
  sides?: number;
  area(...params: number[]): number;
}[] = [rectangle, triangle, circle];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5)}`,
  );
});

Observe como se ha definido un método area en la forma del tipo del array figures, el cual debe recibir un conjunto de argumentos de tipo number y devuelve un valor number. Al mismo tiempo, ahora los objetos rectangle, triangle y circle definen el método area con los parámetros y lógica necesaria para devolver el cálculo de su área en cada caso.

Si definiésemos el método area como opcional en la forma del tipo del array figures y no lo definiésemos en alguno de los objetos incluidos en el array figures, se produciría un error en tiempo de ejecución, dado que, en el bucle forEach, estaríamos intentando invocar un método no definido:

let rectangle = {
  name: "Rectangle",
  sides: 4,
  area: (base: number, height: number) => base * height,
};

let triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
};

let circle = {
  name: "Circle",
  area: (radius: number) => Math.PI * Math.pow(radius, 2),
};

let pentagon = {
  name: "Pentagon",
  sides: 5,
};

const figures: {
  name: string;
  sides?: number;
  area?(...params: number[]): number;
}[] = [rectangle, triangle, circle, pentagon];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5)}`,
  );
});

El error en tiempo de ejecución, debido a que no se ha definido el método area en el objeto pentagon, es el siguiente:

I am a Rectangle, I have 4 sides and my area is 15
I am a Triangle, I have 3 sides and my area is 7.5
I am a Circle, I have undefined sides and my area is 28.274333882308138
/home/usuario/DSI/theory-examples/dist/index.js:21
    console.log(`I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5)}`);
                                                                                             ^

TypeError: figure.area is not a function
    at /home/usuario/DSI/theory-examples/dist/index.js:21:94
    ...

El compilador de TypeScript puede informar de un error cuando se intenta invocar un método que ha sido especificado como opcional en la forma del tipo de un objeto y cuyo valor podría ser undefined. Para ello, se debe habilitar la opción strictNullChecks del compilador en el fichero tsconfig.json. Haciendo lo anterior, el compilador informa del siguiente error al recompilar el código fuente del último ejemplo:

src/index.ts:31:75 - error TS2722: Cannot invoke an object which is possibly 'undefined'.

31     `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5)}`,
                                                                             ~~~~~~~~~~~


Found 1 error in src/index.ts:31

Alias de tipos

Se puede utilizar un alias de tipo para definir la forma del tipo de un objeto:

type Figure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

let rectangle: Figure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
};

let triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
};

let circle = {
  name: "Circle",
  area: (radius: number) => Math.PI * Math.pow(radius, 2),
};

const figures: Figure[] = [rectangle, triangle, circle];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5).toFixed(2)}`,
  );
});

Se puede observar como hemos definido el tipo Figure, el cual es utilizado más tarde en la definición del array figures. Dicho tipo también se ha utilizado para anotar explícitamente el tipo del objeto rectangle. De hecho, gracias a la anotación de tipo en rectangle, no es necesario anotar los tipos de los argumentos de su método area, dado que el compilador es capaz de inferir que ambos son de tipo number.

El compilador de TypeScript no se basa en los alias de tipos a la hora de llevar a cabo comprobaciones de tipos con objetos. Tal y como se ha mencionado anteriormente, se basa en la forma de los tipos de los objetos y, es por ello, que también podemos asignar al array figures los objetos triangle y circle, a pesar de no haberlos anotado explícitamente con el tipo Figure.

Un caso diferente es aquel que surge cuando se anota un objeto con un alias de tipo como, por ejemplo, el objeto rectangle, que es de tipo Figure, y, además, se incluye alguna propiedad adicional en dicho objeto que no ha sido definida en la forma del tipo Figure:

type Figure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

let rectangle: Figure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
  color: "blue",
};

let triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
  color: "red",
};

let circle = {
  name: "Circle",
  area: (radius: number) => Math.PI * Math.pow(radius, 2),
};

const figures: Figure[] = [rectangle, triangle, circle];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(3, 5).toFixed(2)}`,
  );
});

El compilador de TypeScript interpreta que, muy probablemente, hemos cometido un error a la hora de incluir la propiedad color en la definición de rectangle, dado que dicha propiedad no se encuentra definida en la forma del tipo Figure:

src/index.ts:11:3 - error TS2353: Object literal may only specify known properties, and 'color' does not exist in type 'Figure'.

11   color: "blue",
     ~~~~~


Found 1 error in src/index.ts:11

Uniones de tipo en objetos

Como era de esperar, podemos utilizar uniones de tipos que involucren diferentes formas de tipos de objetos:

type TwoDimensionalFigure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

type ThreeDimensionalFigure = {
  name: string;
  volume(...params: number[]): number;
};

let rectangle: TwoDimensionalFigure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
};

let cube: ThreeDimensionalFigure = {
  name: "Cube",
  volume: (base, height, depth) => base * height * depth,
};

const figures: (TwoDimensionalFigure | ThreeDimensionalFigure)[] = [
  rectangle,
  cube,
];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}`);
});

En el ejemplo anterior hemos definido dos alias para dos formas de tipos de objetos diferentes denominados TwoDimensionalFigure y ThreeDimensionalFigure. Además, ahora el array figures se ha anotado con la unión de tipos TwoDimensionalFigure | ThreeDimensionalFigure, por lo que puede contener objetos cuyas formas de tipos coincidan con las formas de los tipos TwoDimensionalFigure y ThreeDimensionalFigure.

La única propiedad común existente en ambas formas de tipos es la propiedad name y, por tanto, la única que es accesible directamente, algo que ya sabíamos a la hora de trabajar con uniones de tipos básicos. Por lo tanto, acceder directamente a las propiedades area o volume, por ejemplo, en el bucle forEach, implicaría que el compilador informase de un error.

Por último, cabe mencionar que, cuando se define una unión de tipos que involucra diferentes formas de tipos de objetos, el tipo de cada propiedad de la unión de tipos, esto es, el tipo de las propiedades comunes, es también una unión de tipos.

Uso de guardianes de tipos con objetos

Anteriormente, a la hora de utilizar guardianes de tipos, se ha utilizado typeof para tratar de averiguar los tipos de datos. No obstante, en el caso de utilizar guardianes de tipos con objetos, typeof no puede usarse, dado que siempre devuelve "object" en el caso de aplicarlo a un objeto:

type TwoDimensionalFigure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

type ThreeDimensionalFigure = {
  name: string;
  volume(...params: number[]): number;
};

let rectangle: TwoDimensionalFigure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
};

let cube: ThreeDimensionalFigure = {
  name: "Cube",
  volume: (base, height, depth) => base * height * depth,
};

const figures: (TwoDimensionalFigure | ThreeDimensionalFigure)[] = [
  rectangle,
  cube,
];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name} and my type is ${typeof figure}`);
});

Teniendo en cuenta lo anterior, se puede aplicar in en lugar de typeof para establecer guardianes de tipos con objetos, lo que permite diferenciar formas diferentes de tipos de objetos llevando a cabo comprobaciones sobre la existencia de propiedades concretas en los mismos:

type TwoDimensionalFigure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

type ThreeDimensionalFigure = {
  name: string;
  volume(...params: number[]): number;
};

let rectangle: TwoDimensionalFigure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
};

let cube: ThreeDimensionalFigure = {
  name: "Cube",
  volume: (base, height, depth) => base * height * depth,
};

const figures: (TwoDimensionalFigure | ThreeDimensionalFigure)[] = [
  rectangle,
  cube,
];

figures.forEach((figure) => {
  if ("area" in figure) {
    console.log(
      `I am a ${figure.name} and my area is ${figure.area(5, 4).toFixed(2)}`,
    );
  } else if ("volume" in figure) {
    console.log(
      `I am a ${figure.name} and my volume is ${figure.volume(5, 4, 7).toFixed(2)}`,
    );
  }
});

En el ejemplo anterior se ha establecido un guardián de tipos que permite comprobar si el método area o el método volume existen en un objeto cuyo tipo es TwoDimensionalFigure | ThreeDimensionalFigure, lo que permite deshacer la unión de tipos e invocar dichos métodos sin que el compilador de TypeScript emita un error.

Los guardianes de tipos deben incluir sentencias condicionales lo más precisas posible, de modo que las diferentes formas de los tipos de los objetos involucradas puedan diferenciarse adecuadamente en cada rama del guardián de tipos sin que el compilador de TypeScript emita errores. Ejemplos de sentencias condicionales inapropiadas en el uso de guardianes de tipos con objetos podrían ser una sentencia condicional que compruebe la existencia de una propiedad que se ha definido en más de una forma del tipo de un objeto perteneciente a la unión de tipos, o una sentencia condicional que compruebe la existencia de una propiedad de un objeto definida como opcional.

Por último, cabe mencionar que, como método alternativo al uso de in, se puede utilizar una función para establecer un guardián de tipos con objetos:

type TwoDimensionalFigure = {
  name: string;
  sides?: number;
  area(...params: number[]): number;
};

type ThreeDimensionalFigure = {
  name: string;
  volume(...params: number[]): number;
};

let rectangle: TwoDimensionalFigure = {
  name: "Rectangle",
  sides: 4,
  area: (base, height) => base * height,
};

let cube: ThreeDimensionalFigure = {
  name: "Cube",
  volume: (base, height, depth) => base * height * depth,
};

const figures: (TwoDimensionalFigure | ThreeDimensionalFigure)[] = [
  rectangle,
  cube,
];

function isTwoDimensionalFigure(myObj: unknown): myObj is TwoDimensionalFigure {
  return typeof myObj === "object" && myObj !== null && "area" in myObj;
}

function isThreeDimensionalFigure(myObj: unknown): myObj is ThreeDimensionalFigure {
  return typeof myObj === "object" && myObj !== null && "volume" in myObj;
}

figures.forEach((figure) => {
  if (isTwoDimensionalFigure(figure)) {
    console.log(
      `I am a ${figure.name} and my area is ${figure.area(5, 4).toFixed(2)}`,
    );
  } else if (isThreeDimensionalFigure(figure)) {
    console.log(
      `I am a ${figure.name} and my volume is ${figure.volume(5, 4, 7).toFixed(2)}`,
    );
  }
});

La función isTwoDimensionalFigure es una función especial que hace uso de is y cuyo resultado proviene de la evaluación de un predicado de tipos. Gracias al uso de is se puede establecer el tipo que la función pretende comprobar. Si el resultado de la invocación a la función con el parámetro myObj resulta ser true, entonces el compilador de TypeScript tratará a myObj como de tipo TwoDimensionalFigure. De un modo similar, también se ha definido la función isThreeDimensionalFigure en el ejemplo anterior. Además, véase como el argumento myObj de ambas funciones es de tipo unknown. Debido a esto último, en la sentencia condicional es necesario comprobar, antes de averiguar si una propiedad como area o volume forma parte del objeto myObj, comprobar que el tipo de myObj es object.

Intersecciones de tipos

Al igual que en TypeScript se pueden definir uniones de tipos, también se pueden definir intersecciones de tipos. Las intersecciones de tipos son especialmente útiles a la hora de introducir nueva funcionalidad en un objeto, o a la hora de mezclar objetos cuyos tipos tengan formas diferentes de modo que se pueda combinar la información contenida en los mismos.

A diferencia de una unión de tipos, donde solo son accesibles las características comunes a todos los tipos que forman parte de la unión, en una intersección de tipos son accesibles todas las características de los tipos que forman parte de la intersección, ya sean comunes o no comunes:

type TwoDimensionalFigure = {
  name: string;
  area(...params: number[]): number;
};

type TwoDimensionalFigureWithSides = {
  name: string;
  sides: number;
};

const rectangle = {
  name: "Rectangle",
  sides: 4,
  area: (base: number, height: number) => base * height,
};

const triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
};

const figures: (TwoDimensionalFigure & TwoDimensionalFigureWithSides)[] = [
  rectangle,
  triangle,
];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(5, 4).toFixed(2)}`,
  );
});

Tal y como puede observarse, el array figures se ha anotado con la intersección de tipos TwoDimensionalFigure & TwoDimensionalFigureWithSides. Las intersecciones se definen mediante el uso del caracter &.

Además, un objeto como, por ejemplo, rectangle o triangle, puede formar parte del array figures porque en cada uno de ellos se han definido todas y cada una de las propiedades definidas en los tipos TwoDimensionalFigure y TwoDimensionalFigureWithSides por separado. Si alguna de las propiedades name, sides o area no se definieran, el compilador de TypeScript informaría de un error:

type TwoDimensionalFigure = {
  name: string;
  area(...params: number[]): number;
};

type TwoDimensionalFigureWithSides = {
  name: string;
  sides: number;
};

const rectangle = {
  name: "Rectangle",
  sides: 4,
  area: (base: number, height: number) => base * height,
};

const triangle = {
  name: "Triangle",
  sides: 3,
  area: (base: number, height: number) => (base * height) / 2,
};

const circle = {
  name: "Circle",
  area: (radius: number) => Math.PI * Math.pow(radius, 2),
};

const figures: (TwoDimensionalFigure & TwoDimensionalFigureWithSides)[] = [
  rectangle,
  triangle,
  circle,
];

figures.forEach((figure) => {
  console.log(
    `I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(5, 4).toFixed(2)}`,
  );
});

El objeto circle no define la propiedad sides, la cual tiene que estar definida obligatoriamente para que pueda formar parte del array figures, cuyo tipo es una intersección de tipos. El error del compilador es el siguiente:

src/index.ts:31:3 - error TS2322: Type '{ name: string; area: (radius: number) => number; }' is not assignable to type 'TwoDimensionalFigure & TwoDimensionalFigureWithSides'.
  Property 'sides' is missing in type '{ name: string; area: (radius: number) => number; }' but required in type 'TwoDimensionalFigureWithSides'.

31   circle,
     ~~~~~~

  src/index.ts:8:3
    8   sides: number;
        ~~~~~
    'sides' is declared here.


Found 1 error in src/index.ts:31

Especial cuidado hay que tener a la hora de crear intersecciones entre formas de tipos de objetos que comparten propiedades/métodos con el mismo nombre, dado que el tipo de las propiedades/métodos de la intersección consistirá en la intersección de los tipos de las propiedades/métodos por separado. Existen tres casos a diferenciar:

  1. Propiedades con el mismo nombre y tipo. En realidad, no implica problema alguno. Un ejemplo es la propiedad name de tipo string en el ejemplo anterior.
  2. Propiedades con el mismo nombre y tipo diferente. Se mantiene el nombre de la propiedad, pero se intersecta el tipo. De este modo, si tenemos una propiedad name de tipo string en un objeto y una propiedad name de tipo number en el otro objeto, la propiedad name de la intersección será de tipo string & number, lo que resulta en lo que se denomina un tipo imposible, es decir, un tipo al que no se le pueden asignar valores. Lo anterior siempre sucede que se intersectan tipos básicos como string o number. Para solucionar este tipo de situaciones bastaría con cambiar el nombre de la propiedad name en los diferentes objetos, o hacer que dicha propiedad pase a ser un objeto en sí misma en todos los objetos.
  3. Métodos con el mismo nombre. Se mantiene el nombre del método y se crea una función cuya signatura es una intersección de las signaturas de los métodos definidos en cada objeto. Es difícil predecir el comportamiento de un método intersectado, pero suele ser similar al de un método cuyos tipos han sido sobrecargados. Por último, la implementación de un método intersectado debe preservar la compatibilidad con los métodos definidos en cada objeto que forma parte de la intersección. Para ello, los parámetros de una función intersectada suelen definirse mediante uniones de tipos y el valor de retorno suele tomar el tipo de datos any. Además, se suelen utilizar guardianes de tipos en el cuerpo de la función intersectada para establecer las relaciones entre los tipos de los parámetros y los tipos del resultado.

Clases

En primer lugar, modifique el contenido del fichero de configuración del compilador de TypeScript para que contenga lo siguiente:

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

Tal y como se comentó en la sección anterior, además de poder crear objetos mediante el uso de sintaxis literal, los objetos también pueden crearse a partir de la utilización de constructores o clases. El siguiente ejemplo ilustra la definición de un constructor denominado TwoDimensionalFigure que permite construir objetos:

let TwoDimensionalFigure = function(name: string, sides: number,
    area: (...params: number[]) => number) {
  this.name = name;
  this.sides = sides;
  this.area = area;
};

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(5, 4).toFixed(2)}`);
});

No obstante, el ejemplo anterior tiene un problema. Si se activa la opción noImplicitAny del compilador de TypeScript, la cual suele estar activada, el compilador informa del siguiente error:

src/index.ts:9:1 - error TS7009: 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.

9 new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/index.ts:12:1 - error TS7009: 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.

12 new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: src/index.ts:9

Cuando se invoca el constructor TwoDimensionalFigure mediante new, el compilador de TypeScript infiere de manera implícita que los objetos creados y asignados a las variables rectangle y triangle son de tipo any, por lo que informa de los errores mostrados con anterioridad.

Lo anterior ilustra que el uso de constructores para crear objetos no es una buena práctica en TypeScript y, por ello, es mejor utilizar clases. El siguiente ejemplo ilustra la definición de una clase con la misma funcionalidad que la proporcionada por el constructor del ejemplo anterior:

class TwoDimensionalFigure {
  name: string;
  sides: number;
  area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this.sides = sides;
    this.area = area;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides and my area is ${figure.area(5, 4).toFixed(2)}`);
});

A medida que vayamos incorporando características a nuestra clase TypeScript, será interesante ver la clase JavaScript equivalente que el compilador genera. Teniendo en cuenta el ejemplo anterior, la clase JavaScript que se genera es la que sigue:

class TwoDimensionalFigure {
    name;
    sides;
    area;
    constructor(name, sides, area) {
        this.name = name;
        this.sides = sides;
        this.area = area;
    }
}

Tipos de acceso a las propiedades, getters y setters

TypeScript soporta el uso de las siguientes palabras clave para definir el nivel de acceso a una propiedad de la clase:

  • public: Se puede acceder libremente a la propiedad o método de la clase. Es el acceso por defecto.
  • private: Solo se puede acceder desde la clase donde se define la propiedad o método.
  • protected: Solo se puede acceder desde la clase donde se define la propiedad o método o desde clases hijas.

El siguiente ejemplo ilustra lo anterior:

class TwoDimensionalFigure {
  public name: string;
  private _sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this._sides = sides;
    this.area = area;
  }

  get sides() {
    return this._sides;
  }

  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure._sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Lo primero que hay que indicar es que todos los miembros de una clase son públicos por defecto, por lo que las anotaciones con public se recomiendan, solamente, por cuestiones de legibilidad del código.

En segundo lugar, véase como se ha declarado un getter y setter para la propiedad _sides a través de las palabras reservadas get y set, respectivamente. La propiedad _sides ha sido renombrada porque si se le dejara el nombre sides (sin guión bajo), surgiría un conflicto de nombres duplicados con el nombre del getter y setter. Respecto a los getters y setters, TypeScript aplica algunas reglas de inferencia:

  • Si existe get pero no existe set, la propiedad correspondiente será de solo lectura (readonly). Pruebe a eliminar del ejemplo anterior el setter y a llevar a cabo, después de instanciar triangle a través del constructor, la siguiente asignación: triangle.sides = 3. El compilador informará de que sides es una propiedad de solo lectura.
  • Si el tipo del parámetro de set no se anota explícitamente, dicho tipo será inferido a partir del tipo de retorno del get. En el ejemplo anterior, obsérvese como el compilador ha inferido el tipo number para el argumento sides del setter, dado que el getter correspondiente devuelve un number.
  • El getter get debe ser, al menos, tan accesible como el setter set. Trate de anotar el getter como privado. Lo anterior hará que el getter sea menos accesible que el correspondiente setter, algo de lo que informará el compilador.

Obsérvese como la propiedad _sides se ha definido como privada. Al intentar acceder a la misma desde fuera de la clase, el compilador de TypeScript informa del siguiente error:

src/index.ts:31:55 - error TS2341: Property '_sides' is private and only accessible within class 'TwoDimensionalFigure'.

31   console.log(`I am a ${figure.name}, I have ${figure._sides} sides ` +
                                                         ~~~~~~


Found 1 error in src/index.ts:31

Por lo tanto, la única manera de acceder a la propiedad _sides es a través de la invocación del getter sides, el cual es público por defecto:

class TwoDimensionalFigure {
  public name: string;
  private _sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this._sides = sides;
    this.area = area;
  }

  get sides() {
    return this._sides;
  }

  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Si se echa un vistazo a la clase JavaScript generada en este caso, se comprobará que la funcionalidad respecto a la definición del nivel de acceso a una propiedad o método de una clase es exclusiva de TypeScript:

class TwoDimensionalFigure {
    name;
    _sides;
    area;
    constructor(name, sides, area) {
        this.name = name;
        this._sides = sides;
        this.area = area;
    }
    get sides() {
        return this._sides;
    }
    set sides(sides) {
        this._sides = sides;
    }
}

Por lo tanto, los modificadores de acceso private y protected de TypeScript solo entran en juego a la hora de comprobar tipos. Tal y como puede observarse en el código JavaScript generado, no habría problema, por ejemplo, en acceder desde fuera de la clase a una propiedad como _sides, la cual ha sido definida como privada en TypeScript:

class TwoDimensionalFigure {
    name;
    _sides;
    area;
    constructor(name, sides, area) {
        this.name = name;
        this._sides = sides;
        this.area = area;
    }
    get sides() {
        return this._sides;
    }
    set sides(sides) {
        this._sides = sides;
    }
}

let rectangle = new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);
console.log(rectangle._sides);

Incluso, en TypeScript podríamos hacer algo como lo siguiente:

class TwoDimensionalFigure {
  public name: string;
  private _sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this._sides = sides;
    this.area = area;
  }

  get sides() {
    return this._sides;
  }

  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

console.log(rectangle["_sides"]);

A pesar de usar el modificador de acceso private sobre _sides, se puede acceder a dicho miembro desde fuera de la clase si se usa la notación basada en corchetes, que es la que se muestra en la última línea del ejemplo anterior. Debido a esto, se dice que _sides es un miembro soft private ya que el modificador private no fuerza la privacidad en un sentido estricto.

A diferencia del modificador de acceso private de TypeScript, un miembro privado de JavaScript, anotado mediante el uso de #, permanece privado después de la compilación. En estos casos, se dice que el miembro de la clase es hard private:

class TwoDimensionalFigure {
  public name: string;
  #sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this.#sides = sides;
    this.area = area;
  }

  get sides() {
    return this.#sides;
  }

  set sides(sides) {
    this.#sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

console.log(rectangle.#sides);

El compilador emite el siguiente error ante el ejemplo anterior:

src/index.ts:25:23 - error TS18013: Property '#sides' is not accessible outside class 'TwoDimensionalFigure' because it has a private identifier.

25 console.log(rectangle.#sides);
                         ~~~~~~


Found 1 error in src/index.ts:25

Además, obsérvese el código JavaScript generado:

class TwoDimensionalFigure {
    name;
    #sides;
    area;
    constructor(name, sides, area) {
        this.name = name;
        this.#sides = sides;
        this.area = area;
    }
    get sides() {
        return this.#sides;
    }
    set sides(sides) {
        this.#sides = sides;
    }
}
let rectangle = new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);
console.log(rectangle.#sides);

Al intentar ejecutar el código anterior, directamente, se produce un error en tiempo de ejecución:

$node dist/index.js 
/home/usuario/DSI/theory-examples/dist/index.js:18
console.log(rectangle.#sides);
                     ^

SyntaxError: Private field '#sides' must be declared in an enclosing class
   ... 

Cabe mencionar en este punto que una buena práctica consiste en tener inicializadas todas las propiedades definidas en una clase, por lo que se recomienda habilitar la opción strictPropertyInitialization del compilador de TypeScript, la cual solo tiene efecto si también está habilitada la opción strictNullChecks.

Por último, dado que el uso de getters y setters es algo muy común, en TypeScript se ha incluido el concepto de que una propiedad de una clase sea auto-accesible, esto es, se le va a proveer de un getter y un setter de manera implícita. Para ello, la propiedad en cuestión se debe preceder de la palabra reservada accessor, tal y como ocurre en el siguiente ejemplo con la propiedad sides:

class TwoDimensionalFigure {
  public name: string;
  accessor sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this.sides = sides;
    this.area = area;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

rectangle.sides = 5;
triangle.sides = 4;

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Se puede observar como, gracias a haber indicado que la propiedad sides es auto-accesible, después de las invocaciones al constructor de TwoDimensionalFigure se puede acceder a dicha propiedad, tanto para modificarla, como para obtener su valor actual. El código JavaScript producido se muestra a continuación:

class TwoDimensionalFigure {
    name;
    #sides_accessor_storage;
    get sides() { return this.#sides_accessor_storage; }
    set sides(value) { this.#sides_accessor_storage = value; }
    area;
    constructor(name, sides, area) {
        this.name = name;
        this.sides = sides;
        this.area = area;
    }
}

Se puede observar como se ha generado una propiedad anotada como privada #sides_accessor_storage, además del correspondiente getter y setter que permite acceder a la misma.

Propiedades de solo lectura

Además de poder definir el nivel de acceso de una propiedad tal y como se ha descrito anteriormente, una propiedad puede marcarse como de solo lectura mediante readonly:

class TwoDimensionalFigure {
  public readonly name: string;
  private _sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this._sides = sides;
    this.area = area;
  }

  get sides() {
    return this._sides;
  }
  
  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

rectangle.name = "circle";

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Al intentar compilar el ejemplo anterior, el compilador informa del siguiente error:

src/index.ts:30:11 - error TS2540: Cannot assign to 'name' because it is a read-only property.

30 rectangle.name = "circle";
             ~~~~


Found 1 error in src/index.ts:30

El constructor de una clase es el único capaz de inicializar una propiedad definida como de solo lectura.

Incluso, en el siguiente ejemplo, si una propiedad se marca como de solo lectura (_sides), no se puede llegar a definir un setter para la misma:

class TwoDimensionalFigure {
  public readonly name: string;
  private readonly _sides: number;
  public area: (...params: number[]) => number;

  constructor(name: string, sides: number,
      area: (...params: number[]) => number) {
    this.name = name;
    this._sides = sides;
    this.area = area;
  }

  get sides() {
    return this._sides;
  }
  
  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Ante el ejemplo anterior, el compilador informa de lo siguiente:

src/index.ts:18:10 - error TS2540: Cannot assign to '_sides' because it is a read-only property.

18     this._sides = sides;
            ~~~~~~


Found 1 error in src/index.ts:18

Constructores simplificados

La definición de un constructor de una clase puede simplificarse, siempre y cuando deseemos que nuestra clase contenga las mismas propiedades que las definidas como parámetros del constructor. Para utilizar esa sintaxis simplificada, lo único que hay que hacer es utilizar las palabras reservadas vistas en secciones anteriores a la hora de definir los parámetros del constructor:

class TwoDimensionalFigure {
  constructor(public readonly name: string, private _sides: number,
      public area: (...params: number[]) => number) {
  }

  get sides() {
    return this._sides;
  }

  set sides(sides) {
    this._sides = sides;
  }
}

let rectangle =
  new TwoDimensionalFigure("Rectangle", 4, (base, height) => base * height);

let triangle =
  new TwoDimensionalFigure("Triangle", 3, (base, height) => base * height / 2);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.area(5, 4).toFixed(2)}`);
});

Ahora, el constructor no tiene ninguna sentencia en su cuerpo pero, automáticamente, crea las propiedades cuyo nivel de acceso también se ha definido y las inicializa con los correspondientes valores pasados como argumentos durante su invocación. Además, también puede observarse como no es necesario declarar dichas propiedades al principio de la clase, tal y como hacíamos en ejemplos anteriores.

Herencia de clases

TypeScript permite la herencia de clases:

class TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
  }
}

class Rectangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height;
  }
}

class Triangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red");

let triangle =
  new Triangle("BlueTriangle", 3, "blue");

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea(5, 4).toFixed(2)}`);
});

Por motivos de simplicidad, hasta ahora, se han definido varias clases en un mismo fichero, pero es una buena práctica definir una clase por fichero.

Tal y como puede observarse en el ejemplo anterior, se han creado dos nuevas clases Rectangle y Triangle que heredan (extends) de la clase TwoDimensionalFigure. Cuando una clase hereda de otra, la subclase debe invocar al constructor de la superclase mediante el uso de super. Además, ambas clases hijas han definido un método getArea que lleva a cabo el cómputo del área de la figura correspondiente, lo que permite eliminar el método area de la clase TwoDimensionalFigure que veníamos usando hasta ahora.

Además, el tipo inferido por el compilador para el array figures es (Rectangle | Triangle)[]. Se podría haber esperado el tipo TwoDimensionalFigure[], pero realmente, el compilador crea una unión de los tipos involucrados en el array, a pesar de que ambas clases hereden de TwoDimensionalFigure.

Un ejemplo diferente sería el siguiente, en el que hemos asignado al array figures un objeto de la clase TwoDimensionalFigure:

class TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
  }
}

class Rectangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height;
  }
}

class Triangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red");

let triangle =
  new Triangle("BlueTriangle", 3, "blue");

let twoDimensionalFigure =
  new TwoDimensionalFigure("anyBlackFigure", 8, "black");

let figures = [twoDimensionalFigure, rectangle, triangle];

figures.forEach((figure) => {
  if (figure instanceof Rectangle || figure instanceof Triangle) {
    console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea(5, 4).toFixed(2)}`);
  }
});

En el caso anterior, en primer lugar, el compilador infiere el tipo (TwoDimensionalFigure | Rectangle | Triangle)[] y, dado que las propiedades de una unión de tipos vienen dadas por las propiedades comunes a todos los tipos de la unión, esas propiedades, precisamente, son las de TwoDimensionalFigure. Es por ello que el tipo inferido para el array figures en este caso particular es TwoDimensionalFigure[].

Además, también hemos tenido que incluir un guardián de tipos, a través del uso de instanceof, para poder deshacer la unión e invocar al método getArea, el cual si que está presente en las clases Rectangle y Triangle y, por lo tanto, en la unión de tipos Rectangle | Triangle que es el tipo inferido para la variable figure dentro de la sentencia if.

En el código fuente JavaScript que se genera al compilar el ejemplo de más arriba, se puede observar como la herencia de clases es una característica también presente en JavaScript:

class TwoDimensionalFigure {
    name;
    sides;
    color;
    constructor(name, sides, color) {
        this.name = name;
        this.sides = sides;
        this.color = color;
    }
}
class Rectangle extends TwoDimensionalFigure {
    name;
    sides;
    color;
    constructor(name, sides, color) {
        super(name, sides, color);
        this.name = name;
        this.sides = sides;
        this.color = color;
    }
    getArea(base, height) {
        return base * height;
    }
}
class Triangle extends TwoDimensionalFigure {
    name;
    sides;
    color;
    constructor(name, sides, color) {
        super(name, sides, color);
        this.name = name;
        this.sides = sides;
        this.color = color;
    }
    getArea(base, height) {
        return base * height / 2;
    }
}

Clases abstractas

Una clase abstracta en TypeScript, al igual que sucede en otros lenguajes, permite modelar situaciones en las que se requiere de un conjunto de características comunes que deben ser implementadas por las clases que heredan de dicha clase abstracta.

abstract class TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
  }

  abstract getArea(...params: number[]): number;
}

class Rectangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  /* getArea(base: number, height: number) {
    return base * height;
  } */
}

class Triangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red");

let triangle =
  new Triangle("BlueTriangle", 3, "blue");

let twoDimensionalFigure =
  new TwoDimensionalFigure("anyBlackFigure", 8, "black");

let figures = [twoDimensionalFigure, rectangle, triangle];

figures.forEach((figure) => {
  if (figure instanceof Rectangle || figure instanceof Triangle) {
    console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea(5, 4).toFixed(2)}`);
  }
});

Se puede observar como, además de definir la clase TwoDimensionalFigure como abstracta, también se ha definido como abstracto el método getArea, el cual debe ser implementado por todas las subclases que hereden de TwoDimensionalFigure. La implementación del método getArea ha sido comentada en la clase Rectangle. Al tratar de compilar el ejemplo anterior, el compilador de TypeScript informa de los siguientes errores:

src/index.ts:9:7 - error TS2515: Non-abstract class 'Rectangle' does not implement inherited abstract member getArea from class 'TwoDimensionalFigure'.

9 class Rectangle extends TwoDimensionalFigure {
        ~~~~~~~~~

src/index.ts:38:3 - error TS2511: Cannot create an instance of an abstract class.

38   new TwoDimensionalFigure("anyBlackFigure", 8, "black");
     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: src/index.ts:9

El primer error indica que la clase Rectangle, la cual hereda de una clase abstracta, requiere de la implementación del método getArea, el cual también ha sido definido como abstracto en Rectangle. Al mismo tiempo, el segundo error informa de que no es posible instanciar la clase abstracta TwoDimensionalFigure.

Ambos errores pueden solucionarse, en primer lugar, eliminando la sentencia que trata de instanciar un objeto de la clase TwoDimensionalFigure y, en segundo lugar, descomentando la implementación del método getArea de Rectangle. Además, obsérvese que, ahora, el array figures puede anotarse explícitamente con el tipo TwoDimensionalFigure[]. Lo anterior permite que ya no sea necesario el guardián de tipos utilizado en ejemplos anteriores para poder invocar al método getArea:

abstract class TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
  }

  abstract getArea(...params: number[]): number;
}

class Rectangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height;
  }
}

class Triangle extends TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string) {
    super(name, sides, color);
  }

  getArea(base: number, height: number) {
    return base * height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red");

let triangle =
  new Triangle("BlueTriangle", 3, "blue");

let figures: TwoDimensionalFigure[] = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea(5, 4).toFixed(2)}`);
});

Interfaces

En TypeScript, una interfaz permite describir la forma de un objeto. La forma de los objetos instanciados a partir de una clase que implemente dicha interfaz deberá coincidir con la forma especificada mediante la interfaz. Una interfaz tiene el mismo propósito que las formas de tipos vistas al principio de este capítulo.

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
}

class Rectangle implements TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }
}

class Triangle implements TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red", 10, 5);

let triangle =
  new Triangle("BlueTriangle", 3, "blue", 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea().toFixed(2)}`);
});

Ahora, TwoDimensionalFigure es una interfaz. Las clases que implementan (implements) dicha interfaz, que son Rectangle y Triangle, deben definir todas y cada una de las propiedades y métodos definidos en la interfaz.

Por ejemplo, si eliminamos la definición del parámetro color del constructor de la clase Triangle:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
}

class Rectangle implements TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }
}

class Triangle implements TwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red", 10, 5);

let triangle =
  new Triangle("BlueTriangle", 3, 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  console.log(`I am a ${figure.name}, I have ${figure.sides} sides ` +
              `and my area is ${figure.getArea().toFixed(2)}`);
});

El compilador de TypeScript informará del siguiente error:

src/index.ts:18:7 - error TS2420: Class 'Triangle' incorrectly implements interface 'TwoDimensionalFigure'.
  Property 'color' is missing in type 'Triangle' but required in type 'TwoDimensionalFigure'.

18 class Triangle implements TwoDimensionalFigure {
         ~~~~~~~~

  src/index.ts:4:3
    4   color: string;
        ~~~~~
    'color' is declared here.


Found 1 error in src/index.ts:18

Implementación de varias interfaces

Una clase puede implementar varias interfaces, como en el ejemplo que sigue, en el que se ha declarado una segunda interfaz Printable que cuenta con un método print, el cual también deben implementar, de manera obligatoria, las clases Rectangle y Triangle:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
}

interface Printable {
  print(): void;
}

class Rectangle implements TwoDimensionalFigure, Printable {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }

  print() {
    console.log(`I am a ${this.name}, I have ${this.sides} sides ` +
              `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class Triangle implements TwoDimensionalFigure, Printable {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }

  print() {
    console.log(`I am a ${this.name}, I have ${this.sides} sides ` +
              `and my area is ${this.getArea().toFixed(2)}`);
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red", 10, 5);

let triangle =
  new Triangle("BlueTriangle", 3, "blue", 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  figure.print();
});

Herencia de interfaces

En TypeScript, las interfaces se pueden extender, haciendo uso de la palabra reservada extends, tal y como se puede hacer con las clases. El resultado es una interfaz con las propiedades y métodos de las interfaces de las que hereda, junto con las nuevas propiedades y métodos definidos en la propia interfaz:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
}

interface PrintableTwoDimensionalFigure extends TwoDimensionalFigure {
  print(): void;
}

class Rectangle implements PrintableTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }

  print() {
    console.log(`I am a ${this.name}, I have ${this.sides} sides ` +
              `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class Triangle implements PrintableTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }

  print() {
    console.log(`I am a ${this.name}, I have ${this.sides} sides ` +
              `and my area is ${this.getArea().toFixed(2)}`);
  }
}

let rectangle =
  new Rectangle("RedRectangle", 4, "red", 10, 5);

let triangle =
  new Triangle("BlueTriangle", 3, "blue", 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  figure.print();
});

En el ejemplo se ha definido una interfaz PrintableTwoDimensionalFigure que hereda de la interfaz TwoDimensionalFigure todas sus propiedades y métodos. Ahora, las clases Rectangle y Triangle, en lugar de implementar dos interfaces, solo implementan la interfaz PrintableTwoDimensionalFigure, lo que supone obtener el mismo comportamiento que en el ejemplo de la sección anterior.

Definición de propiedades y métodos opcionales en interfaces

En TypeScript, las interfaces pueden definir propiedades o métodos opcionales, como en el caso del siguiente ejemplo, donde la propiedad color y el método getArea de la interfaz TwoDimensionalFigure han sido definidos como opcionales:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color?: string;
  getArea?(): number;
}

interface PrintableTwoDimensionalFigure extends TwoDimensionalFigure {
  print(): void;
}

class Rectangle implements PrintableTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public base: number, public height: number) {
  }

  print() {
    console.log(`I am a ${this.name} and I have ${this.sides} sides`);
  }
}

class Triangle implements PrintableTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }

  print() {
    console.log(`I am a ${this.name}, I have ${this.sides} sides ` +
              `and my area is ${this.getArea().toFixed(2)}`);
  }
}

let rectangle =
  new Rectangle("Rectangle", 4, 10, 5);

let triangle =
  new Triangle("BlueTriangle", 3, "blue", 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  figure.print();
});

El resultado es que, ahora, una clase que implementa la interfaz PrintableTwoDimensionalFigure, la cual hereda de la interfaz TwoDimensionalFigure, no tiene porqué definir de manera obligatoria la propiedad color y el método getArea. Un ejemplo de lo anterior es la definición de la clase Rectangle.

Implementación de una interfaz abstracta

En TypeScript, las clases abstractas se pueden utilizar para implementar algunas o todas las propiedades incluidas en una interfaz.

En el ejemplo que sigue, todas las propiedades y métodos de la interfaz TwoDimensionalFigure se han declarado como abstractas en la clase abstracta PrintableRedTwoDimensionalFigure, a excepción de la propiedad color a la cual se le asigna la cadena de caracteres "red". Es por ello, que las clases RedRectangle y RedTriangle, las cuales heredan de la clase PrintableRedTwoDimensionalFigure, no requieren de la definición de la propiedad color en sus constructores, dado que la heredan. De lo que si requieren las subclases es de la definición del resto de propiedades y métodos definidos como abstractos en la superclase:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
}

abstract class PrintableRedTwoDimensionalFigure implements TwoDimensionalFigure {
  abstract name: string;
  abstract sides: number;
  abstract getArea(): number;
  abstract print(): void;
  color = "red";
}

class RedRectangle extends PrintableRedTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public base: number, public height: number) {
    super();
  }

  getArea() {
    return this.base * this.height;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.sides} sides, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class RedTriangle extends PrintableRedTwoDimensionalFigure {
  constructor(public readonly name: string, public readonly sides: number,
    public base: number, public height: number) {
    super();
  }

  getArea() {
    return this.base * this.height / 2;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.sides} sides, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

let rectangle =
  new RedRectangle("rectangle", 4, 10, 5);

let triangle =
  new RedTriangle("triangle", 3, 5, 9);

let figures = [rectangle, triangle];

figures.forEach((figure) => {
  figure.print();
});

Guardianes de tipo e interfaces

TypeScript permite definir uniones de tipos en las que intervienen interfaces. Para tratar de deshacer la unión de tipos a favor de una interfaz, no se puede establecer un guardián de tipos que utilice typeof o instanceof y, en su lugar, se debe establecer un guardián de tipos que utilice in, con el objetivo de averiguar si la interfaz en cuestión define una propiedad o conjunto de propiedades concretas.

Debemos recordar que una interfaz es equivalente a la forma del tipo de un objeto, por lo que el comportamiento de los guardianes de tipo en ambos casos será equivalente también. Un ejemplo que ilustra lo anterior es el siguiente:

interface TwoDimensionalFigure {
  name: string;
  sides: number;
  color: string;
  getArea(): number;
  print(): void;
}

interface ThreeDimensionalFigure {
  name: string;
  faces: number;
  color: string;
  getVolume(): number;
  print(): void;
}

class Rectangle implements TwoDimensionalFigure {
  constructor(public name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.sides} sides, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class Cube implements ThreeDimensionalFigure {
  constructor(public name: string, public readonly faces: number,
    public color: string, public base: number,
    public height: number, public depth: number) {
  }

  getVolume() {
    return this.base * this.height * this.depth;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.faces} sides, ` +
                `and my area is ${this.getVolume().toFixed(2)}`);
  }
}

let rectangle =
  new Rectangle('rectangle', 4, 'red', 10, 5);

let cube =
  new Cube('cube', 6, 'blue', 5, 9, 14);

let figures: (TwoDimensionalFigure | ThreeDimensionalFigure)[] =
  [rectangle, cube];

figures.forEach((figure) => {
  if ('getArea' in figure) {
    console.log(`Figure: ${figure.name}; Area: ${figure.getArea()}`);
  } else if ('getVolume' in figure) {
    console.log(`Figure: ${figure.name}; Volume: ${figure.getVolume()}`);
  }
});

Se han definido dos interfaces TwoDimensionalFigure y ThreeDimensionalFigure, además de las clases Rectangle y Cube que implementan, respectivamente, ambas interfaces. Ahora, el array figures es de tipo (TwoDimensionalFigure | ThreeDimensionalFigure)[], es decir, el tipo es una unión de tipos en la que intervienen interfaces, y no clases. En el caso de querer deshacer la unión de tipos para poder invocar a los métodos que son particulares de cada interfaz, lo que se debe hacer es utilizar in en el guardián de tipos establecido al final del ejemplo.

Creación dinámica de propiedades

El compilador de TypeScript solo permite asignar valores a aquellas propiedades definidas en la forma del tipo de un objeto, lo que implica que las clases e interfaces deben definir todas las propiedades y métodos requeridos. Por el contrario, JavaScript permite ampliar un objeto con nuevas propiedades, simplemente, asignando un valor a una propiedad cuyo nombre no se haya usado aún.

En TypeScript se puede utilizar una signatura indexada (index signature) para definir propiedades dinámicamente, al mismo tiempo que se preserva la seguridad de la comprobación de los tipos de datos:

interface TwoDimensionalFigure {
  name: string;
  sides?: number;
  color: string;
  getArea(): number;
  print(): void;
}

class Rectangle implements TwoDimensionalFigure {
  constructor(public name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.sides} sides, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class Triangle implements TwoDimensionalFigure {
  constructor(public name: string, public readonly sides: number,
    public color: string, public base: number, public height: number) {
  }

  getArea() {
    return this.base * this.height / 2;
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `I have ${this.sides} sides, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class Circle implements TwoDimensionalFigure {
  constructor(public name: string, public color: string,
    public radius: number) {
  }

  getArea() {
    return Math.PI * Math.pow(this.radius, 2);
  }

  print() {
    console.log(`I am a ${this.color} ${this.name}, ` +
                `and my area is ${this.getArea().toFixed(2)}`);
  }
}

class TwoDimensionalFiguresSet {
  constructor(...twoDimensionalFigures: [string, TwoDimensionalFigure][]) {
    twoDimensionalFigures.forEach((figure) => {
      this[figure[0]] = figure[1];
    });
  }

  [propertyName: string]: TwoDimensionalFigure;
}

let figures = new TwoDimensionalFiguresSet(
    ['rectangle', new Rectangle('rectangle', 4, 'red', 5, 4)],
    ['triangle', new Triangle('triangle', 3, 'yellow', 10, 15)],
);

// It dinamically assigns to the object figures a property named
// "circle" whose value is a new Circle
figures.circle = new Circle('circle', 'green', 6);

Object.keys(figures).forEach((key) => {
  console.log(key);
  figures[key].print();
});

En el ejemplo anterior, hemos definido la interfaz TwoDimensionalFigure y tres clases que la implementan: Rectangle, Triangle y Circle.

Además, hemos definido la clase TwoDimensionalFigureSet, la cual recibe como argumentos en su constructor un array de tuplas cuya primera componente es una cadena de caracteres y cuya segunda componente es un objeto que implementa la interfaz TwoDimensionalFigure. De este modo, la primera componente de cada tupla pasada al constructor dará nombre a una propiedad del objeto que se cree, mientras que la segunda componente de cada tupla pasada al constructor será el valor asignado a dicha propiedad. Por último, se puede observar como se utiliza una signatura indexada que permitirá añadir propiedades de manera dinámica a un objeto de la clase TwoDimensionalFigureSet:

[propertyName: string]: TwoDimensionalFigure;

En concreto, puede observarse como el nombre de las propiedades que se pueden crear dinámicamente debe ser de tipo string, mientras que el valor asignado a dicha propiedad debe ser un objeto que implemente la interfaz TwoDimensionalFigure. De manera general, el tipo del nombre de las propiedades puede ser string o number, mientras que el valor asignado a las propiedades puede tomar cualquier tipo.

Al mismo tiempo, la variable figures apuntará a una instancia de la clase TwoDimensionalFiguresSet. Una vez inicializada dicha instancia, el objeto al que apunta figures contendrá:

  • Una propiedad denominada 'rectangle', cuyo valor es una instancia de la clase Rectangle, la cual implementa TwoDimensionalFigure.
  • Una propiedad denominada 'triangle', cuyo valor es una instancia de la clase Triangle, la cual implementa TwoDimensionalFigure.

Más adelante, en el código, se puede observar como se añade una nueva propiedad de manera dinámica al objeto apuntado por figures:

figures.circle = new Circle('circle', 'green', 6);

En concreto, se añade dinámicamente la propiedad denominada 'circle' cuyo valor es una instancia de la clase Circle, la cual también implementa la interfaz TwoDimensionalFigure.

Por último, el bucle forEach itera en los diferentes nombres de las propiedades del objeto apuntado por figures y, para cada una de ellas, imprime el nombre de la propiedad por consola e invoca al método print() del valor asignado a cada propiedad. El resultado es el siguiente:

rectangle
I am a red rectangle, I have 4 sides, and my area is 20.00
triangle
I am a yellow triangle, I have 3 sides, and my area is 75.00
circle
I am a green circle, and my area is 113.10

El compilador de TypeScript asume que solo se va a acceder a propiedades existentes a través de una signatura indexada. Supóngase que se modifica el ejemplo para añadir una sentencia que accede a la propiedad inexistente hexagon de figures a través de su signatura indexada:

Object.keys(figures).forEach((key) => {
  console.log(key);
  figures[key].print();
});

figures.hexagon.print();

El compilador de TypeScript no emite ningún error, y al ejecutar el programa se obtiene el siguiente error en tiempo de ejecución:

$node dist/index.js 
rectangle
I am a red rectangle, I have 4 sides, and my area is 20.00
triangle
I am a yellow triangle, I have 3 sides, and my area is 75.00
circle
I am a green circle, and my area is 113.10
/home/usuario/DSI/theory-examples/dist/index.js:77
figures.hexagon.print();
                ^

TypeError: Cannot read properties of undefined (reading 'print')
...

Para evitar lo anterior, se recommienda habilitar las opciones strictNullChecks y noUncheckedIndexedAccess del compilador, que permiten que el mismo emita los siguientes errores a la hora de acceder a propiedades a través de la signatura indexada:

isrc/index.ts:77:3 - error TS2532: Object is possibly 'undefined'.

77   figures[key].print();
     ~~~~~~~~~~~~

src/index.ts:80:1 - error TS18048: 'figures.hexagon' is possibly 'undefined'.

80 figures.hexagon.print();
   ~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: src/index.ts:77

Los anteriores errores se podrían solucionar haciendo una afirmación de tipo no nula en el caso de la primera sentencia, y de un guardián de tipos en el caso de la segunda:

Object.keys(figures).forEach((key) => {
  console.log(key);
  figures[key]!.print();
});

if ('hexagon' in figures) {
  figures.hexagon.print();
}