TypeScript Decorators, No solo es belleza
No puedes detallar un auto con la cubierta puesta
Dominic Toretto
Viernes, 5:55pm, a punto de cerrar tu semana laboral, realizas las últimas pruebas de tu código antes de enviar el commit definitivo y viene la catástrofe.
Todo el esfuerzo invertido minimizado porque el rendimiento no es el esperado y esa funcionalidad en la que estás trabajando, simplemente no lo vale.
Como no eres de los que simplemente abandonan el barco, procedes a depurar todo tu código, por lo que comienzas por todas partes a insertar lineas de console.log
y a medir tiempos.
Varias horas han transcurrido, cuando por fin detectas el problema, te das cuenta que tienes que hacer un rollback completo de tu rama actual, porque llenaste de instrucciones de depuración tu código.
Todo lo que esta descrito anteriormente creo nos ha pasado a mas de uno, y describe uno de los muchos casos en los cuales podemos usar Decoradores para extender nuestro código o simplemente resolver problemas.
Que son Decoradores
Los Decoradores son anotaciones que se hacen tanto en Clases como en sus Métodos, con el fin de modificar o extender el comportamiento de estos mediante Meta programación.
Para poder usar Decoradores con TypeScript en versiones anteriores a 5.0, debemos agregar la siguiente configuración a tsconfig.json
{
...
compilerOptions: {
...
experimentalDecorators: true
...
}
...
}
O durante la ejecución de tsc
con
tsc --target [ES5 o posterior] --experimentalDecorators
A partir de TypeScript 5.0 esta activado por defecto.
Para declarar un Decorador, lo hacemos con una función, que debe recibir una referencia de la Clase, Método o Parámetro que desea extender o modificar
// Ejemplo de Decorador
function MiDecorador(destino) {
// realizar alguna operación o modificación a destino
}
Y para usar dicho Decorador, se usa en forma de “anotación” sobre su destino.
import {MiDecorador} from './mi-decorador';
// A nivel de Clase
@MiDecorador
class MiClase {
// A nivel de Parámetro
@MiDecorador
private propiedad;
// A nivel de Método
@MiDecorador
miMetodo() { }
}
Una vez con lo básico, veamos ejemplos de como implementarlos.
Recurramos a nuestra triste historia de inicio, donde necesitamos evaluar la ejecución de nuestro código, para el caso concreto, cuanto demora en ejecutarse.
Supongamos esta bella clase que genera las coordenadas de un Fractal de Seirpinsky
// seirpinsky.ts
const SIN_60 = Math.sin(Math.PI / 3);
const COS_60 = Math.cos(Math.PI / 3);
type Coords = {
x: number;
y: number;
};
type TriCoords = {
a: Coords;
b: Coords;
c: Coords;
};
class Sierpinsky {
getFractal(
levels: number = 1,
coords = { x: 0, y: 0 },
vertSize: number = 100
): TriCoords[] {
const coordinates = [];
if (levels > 1) {
const triangleA = this.getTriangleCoordinates(coords, vertSize / 2);
coordinates.push(
...this.getFractal(levels - 1, triangleA.a, vertSize / 2)
);
coordinates.push(
...this.getFractal(levels - 1, triangleA.b, vertSize / 2)
);
coordinates.push(
...this.getFractal(levels - 1, triangleA.c, vertSize / 2)
);
return coordinates;
}
return [this.getTriangleCoordinates(coords, vertSize)];
}
private getTriangleCoordinates(coords: Coords, vertSize: number): TriCoords {
const aX = coords.x;
const aY = coords.y;
const bX = aX + vertSize * COS_60;
const bY = aY + vertSize * SIN_60;
const cX = aX + vertSize;
const cY = aY;
return {
a: { x: aX, y: aY },
b: { x: bX, y: bY },
c: { x: cX, y: cY },
};
}
}
No vamos a entrar en detalles, pero hagamos la prueba de ejecutarla, a 2 niveles inicialmente.
// index.ts
import { Sierpinsky } from './sierpinsky';
let nivel = 2;
const fractal = new Sierpinsky();
const fractGen = fractal.getFractal(nivel);
console.log(`Fractal ${nivel}`, fractGen);
// "Fractal 2: ", [{
// a: {
// x: 0,
// y: 0
// },
// b: {
// x: 25.000000000000007,
// y: 43.30127018922193
// },
// c: { ...
console.log(`Fractal ${nivel}, #elementos`, fractGen.length);
// "Fractal 2, #elementos: ", 3
Hasta aquí, todo normal. Pero que sucedería sí, queremos por ejemplo, obtener 10 niveles?
// index.ts
...
let nivel = 10;
const fractal = new Sierpinsky();
const fractGen = fractal.getFractal(nivel);
// En este punto va a demorar, son mas de 19K triangulos a generar...
console.log(`Fractal ${nivel}`, fractGen);
console.log(`Fractal ${nivel}, #elementos`, fractGen.length);
Como podemos apreciar, tenemos un problema de rendimiento, pero no tenemos manera de medir que puede estar afectando nuestro código (bueno, si sabemos pero no es el punto ahora …)
Que mejor manera que medir el tiempo que se tarda en calcular cada fractal
// seirpinsky.ts
...
if (levels > 1) {
const triangleA = this.getTriangleCoordinates(coords, vertSize / 2);
const startTimeA = new Date().getTime();
coordinates.push(
...this.getFractal(levels - 1, triangleA.a, vertSize / 2)
);
const endTimeA = new Date().getTime();
console.log('Tiempo para getFractal:', endTimeA - startTimeA);
// Tiempo para getFractal: 10ms
...
Hasta aquí todo funcionaría, pero ya podemos ver una falencia en este código, y es que tendríamos que repetir las 3 líneas en multiples ocasiones y renombrar las variables en cada repetición, lo que seria un desgaste de esfuerzo y tiempo.
Bienvenidos Decoradores!
Vamos a implementar un decorador para no tener que repetir este código por cada invocación de getFractal
Lo primero es declarar la función del decorador, en este caso
// medir-tiempo.ts
export function MedirTiempo(metodoOriginal) {
// Hacer algo con `metodoOriginal`
}
Y seguido podemos usarla en nuestra clase de la siguiente manera
// sierpinsky.ts
import { MedirTiempo } from './medir-tiempo.ts';
...
export class Sierpinsky {
...
@MedirTiempo
getFractal(
...
Ahora debemos preocuparnos por la implementación de MedirTiempo
porque todavia no hace nada
// medir-tiempo.ts
export function MedirTiempo(metodoOriginal: (...args: any[]) => any, _context: ClassMethodDecoratorContext) {
// _context: ClassMethodDecoratorContext es una buena forma de restringir la aplicacion de nuestro decorador solamente a metodos
function medirTiempoEnMetodo(this: any, ...args: any[]) {
const startTime = new Date().getTime();
const result = metodoOriginal.call(this, ...args);
const endTime = new Date().getTime();
console.log('MedirTiempo: ', endTime - startTime);
return result;
}
return medirTiempoEnMetodo;
}
Como podemos ver, nuestro Decorador se encarga de “Enmascarar” la ejecución del método decorado, retornando una nueva función que será evaluada por nuestro programa en tiempo de ejecución.
Veamos si funciona
MedirTiempo: 0
MedirTiempo: 0
MedirTiempo: 0
MedirTiempo: 0
Fractal 2: [
{
a: { x: 0, y: 0 },
b: { x: 25.000000000000007, y: 43.30127018922193 },
c: { x: 50, y: 0 }
},
{
a: { x: 25.000000000000007, y: 43.30127018922193 },
b: { x: 50.000000000000014, y: 86.60254037844386 },
c: { x: 75, y: 43.30127018922193 }
},
{
a: { x: 50, y: 0 },
b: { x: 75, y: 43.30127018922193 },
c: { x: 100, y: 0 }
}
]
Fractal 2, #elementos: 3
Ya podemos ver que nuestro decorador esta funcionando, aunque no es muy útil ya que no nos muestra informacion relevante a cada ejecución de nuestro método, entonces hagamos algunos cambios
// medir-tiempo.ts
export function MedirTiempo(metodoOriginal: (...args: any[]) => any, _context: ClassMethodDecoratorContext) {
function medirTiempoEnMetodo(this: any, ...args: any[]) {
const startTime = new Date().getTime();
const result = metodoOriginal.call(this, ...args);
const endTime = new Date().getTime();
console.log(`[MedirTiempo][Metodo: ${_context.name.toString()}][Parametros: ${JSON.stringify(args)}]`, endTime - startTime);
return result;
}
return medirTiempoEnMetodo;
}
Lo que hemos hecho es mejorar el formato en el que queremos visualizar nuestro tiempo, en este caso usamos context.name
que nos provee el nombre del método que hemos decorado, y adicionalmente vamos a imprimir los parámetros con los que se ha ejecutado en cada instancia getFractal
[MedirTiempo][Metodo: getFractal][Parametros: [1,{"x":0,"y":0},50]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [1,{"x":25.000000000000007,"y":43.30127018922193},50]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [1,{"x":50,"y":0},50]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [2]] 1
Fractal 2: [
{
a: { x: 0, y: 0 },
b: { x: 25.000000000000007, y: 43.30127018922193 },
c: { x: 50, y: 0 }
},
{
a: { x: 25.000000000000007, y: 43.30127018922193 },
b: { x: 50.000000000000014, y: 86.60254037844386 },
c: { x: 75, y: 43.30127018922193 }
},
{
a: { x: 50, y: 0 },
b: { x: 75, y: 43.30127018922193 },
c: { x: 100, y: 0 }
}
]
Fractal 2, #elementos: 3
Ahora se entiende un poco mejor, y podemos visualizar con mejor granularidad, que tiempo demora cada combinación de parametros que usamos con getFractal
Sin embargo, los tiempos que vemos son demasiado pequeños ya que apenas estamos probando con 2 niveles de fractal, por lo que es hora de subir las apuestas (para esta ejecución no mostraremos los elementos generados), y probaremos con 10 niveles
...
[MedirTiempo][Metodo: getFractal][Parametros: [1,{"x":99.8046875,"y":0},0.1953125]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [2,{"x":99.609375,"y":0},0.390625]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [3,{"x":99.21875,"y":0},0.78125]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [4,{"x":98.4375,"y":0},1.5625]] 0
[MedirTiempo][Metodo: getFractal][Parametros: [5,{"x":96.875,"y":0},3.125]] 7
[MedirTiempo][Metodo: getFractal][Parametros: [6,{"x":93.75,"y":0},6.25]] 8
[MedirTiempo][Metodo: getFractal][Parametros: [7,{"x":87.5,"y":0},12.5]] 16
[MedirTiempo][Metodo: getFractal][Parametros: [8,{"x":75,"y":0},25]] 45
[MedirTiempo][Metodo: getFractal][Parametros: [9,{"x":50,"y":0},50]] 136
[MedirTiempo][Metodo: getFractal][Parametros: [10]] 411
Fractal 10, #elementos 19683
Podemos ver que en total la ejecución de nuestro código duro 411 ms
(para este ejemplo estoy usando ts-node, pero en browser puede ser mas demorado)
Ya con esta informacion, es posible buscar alternativas para optimizar nuestro código, y de igual manera el mismo decorador se puede reutilizar para cualquier método en nuestra aplicacion, sin necesidad de reescribir codigo.
Decorador de Clases
En el caso de las clases el decorador tiene una sintaxis un tanto diferente, ya que este actúa sobre el constructor de la clase.
Para demostrar el uso de los decoradores de clase recurriremos a un ejemplo más sencillo
Supongamos que tenemos algunas entidades (Estudiante, Profesor y Empleado) y todas estas entidades deben tener datos como id y marca de creación.
La implementación tradicional sería algo así como
import { uuid } from 'uuidv4';
class BasePersona {
id: string;
creacion: string;
constructor() {
this.id = uuid();
this.creacion = new Date().toLocaleDateString();
}
}
class Estudiante extends BasePersona {
constructor() {
super();
}
}
class Profesor extends BasePersona {
constructor() {
super();
}
}
class Empleado extends BasePersona {
constructor() {
super();
}
}
Es funcional, pero es posible resolver la repetición de código en el constructor mediante un decorador de clase
// base-persona.ts
import { v4 } from "uuid";
export function BasePersona<T extends { new (...args: any[]): {} }>(
constructor: T,
context: ClassDecoratorContext
) {
return class extends constructor {
id = v4();
creacion = new Date().toLocaleDateString();
};
}
Y ahora podemos reescribir nuestro código de manera mas limpia sin tanta repetición
@BasePersona
class Estudiante {}
@BasePersona
class Profesor {}
@BasePersona
class Empleado {}
const estudiante = new Estudiante();
console.log(estudiante);
// Estudiante {
// id: '49178f72-5222-4f6c-a483-1420b4b03ae0',
// creacion: '9/2/2024'
// }
// id y creacion no hacen parte de Estudiante y por lo tanto no esta disponible para el tipado
console.log((estudiante as any).id);
// 49178f72-5222-4f6c-a483-1420b4b03ae0
Es cierto que hay limitaciones todavia como el tipado, pero es claro que el potencial que tienen las anotaciones con el fin de reducir la cantidad de código necesaria en nuestras aplicaciones.