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:
- Propiedades con el mismo nombre y tipo. En realidad, no implica problema alguno. Un ejemplo es la propiedad
namede tipostringen el ejemplo anterior. - 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
namede tipostringen un objeto y una propiedadnamede tiponumberen el otro objeto, la propiedadnamede la intersección será de tipostring & 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 comostringonumber. Para solucionar este tipo de situaciones bastaría con cambiar el nombre de la propiedadnameen los diferentes objetos, o hacer que dicha propiedad pase a ser un objeto en sí misma en todos los objetos. - 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
getpero no existeset, la propiedad correspondiente será de solo lectura (readonly). Pruebe a eliminar del ejemplo anterior el setter y a llevar a cabo, después de instanciartrianglea través del constructor, la siguiente asignación:triangle.sides = 3. El compilador informará de quesideses una propiedad de solo lectura. - Si el tipo del parámetro de
setno se anota explícitamente, dicho tipo será inferido a partir del tipo de retorno delget. En el ejemplo anterior, obsérvese como el compilador ha inferido el tiponumberpara el argumentosidesdel setter, dado que el getter correspondiente devuelve unnumber. - El getter
getdebe ser, al menos, tan accesible como el setterset. 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 claseRectangle, la cual implementaTwoDimensionalFigure. - Una propiedad denominada
'triangle', cuyo valor es una instancia de la claseTriangle, la cual implementaTwoDimensionalFigure.
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();
}