Explore el poder de los decoradores de TypeScript para la programación con metadatos, orientada a aspectos y mejorar código con patrones declarativos. Una guía completa para desarrolladores.
Decoradores de TypeScript: Dominando Patrones de Programación con Metadatos para Aplicaciones Robustas
En el vasto panorama del desarrollo de software moderno, mantener bases de código limpias, escalables y manejables es primordial. TypeScript, con su potente sistema de tipos y características avanzadas, proporciona a los desarrolladores las herramientas para lograrlo. Entre sus características más intrigantes y transformadoras se encuentran los Decoradores. Aunque todavía es una característica experimental en el momento de escribir este artículo (propuesta en Etapa 3 para ECMAScript), los decoradores son ampliamente utilizados en frameworks como Angular y TypeORM, cambiando fundamentalmente cómo abordamos los patrones de diseño, la programación con metadatos y la programación orientada a aspectos (AOP).
Esta guía completa profundizará en los decoradores de TypeScript, explorando su mecánica, diversos tipos, aplicaciones prácticas y mejores prácticas. Ya sea que estés construyendo aplicaciones empresariales a gran escala, microservicios o interfaces web del lado del cliente, comprender los decoradores te permitirá escribir código TypeScript más declarativo, mantenible y potente.
Entendiendo el Concepto Central: ¿Qué es un Decorador?
En esencia, un decorador es un tipo especial de declaración que se puede adjuntar a una declaración de clase, método, accesor, propiedad o parámetro. Los decoradores son funciones que devuelven un nuevo valor (o modifican uno existente) para el objetivo que están decorando. Su propósito principal es agregar metadatos o cambiar el comportamiento de la declaración a la que se adjuntan, sin modificar directamente la estructura del código subyacente. Esta forma externa y declarativa de aumentar el código es increíblemente poderosa.
Piensa en los decoradores como anotaciones o etiquetas que aplicas a partes de tu código. Estas etiquetas pueden ser leídas o utilizadas por otras partes de tu aplicación o por frameworks, a menudo en tiempo de ejecución, para proporcionar funcionalidad o configuración adicional.
La Sintaxis de un Decorador
Los decoradores llevan el prefijo del símbolo @
, seguido del nombre de la función del decorador. Se colocan inmediatamente antes de la declaración que están decorando.
@MiDecorador
class MiClase {
@OtroDecorador
miMetodo() {
// ...
}
}
Habilitando Decoradores en TypeScript
Antes de poder usar decoradores, debes habilitar la opción del compilador experimentalDecorators
en tu archivo tsconfig.json
. Además, para capacidades avanzadas de reflexión de metadatos (a menudo utilizadas por frameworks), también necesitarás emitDecoratorMetadata
y el polyfill reflect-metadata
.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
También necesitas instalar reflect-metadata
:
npm install reflect-metadata --save
# o
yarn add reflect-metadata
E importarlo en la parte superior del punto de entrada de tu aplicación (por ejemplo, main.ts
o app.ts
):
import "reflect-metadata";
// Tu código de aplicación va aquí
Fábricas de Decoradores: Personalización al Alcance de tu Mano
Aunque un decorador básico es una función, a menudo necesitarás pasar argumentos a un decorador para configurar su comportamiento. Esto se logra usando una fábrica de decoradores. Una fábrica de decoradores es una función que devuelve la función del decorador real. Cuando aplicas una fábrica de decoradores, la llamas con sus argumentos, y luego devuelve la función del decorador que TypeScript aplica a tu código.
Creando un Ejemplo Simple de Fábrica de Decoradores
Vamos a crear una fábrica para un decorador Logger
que pueda registrar mensajes con diferentes prefijos.
function Logger(prefix: string) {
return function (target: Function) {
console.log(`[${prefix}] La clase ${target.name} ha sido definida.`);
};
}
@Logger("APP_INIT")
class ApplicationBootstrap {
constructor() {
console.log("La aplicación se está iniciando...");
}
}
const app = new ApplicationBootstrap();
// Salida:
// [APP_INIT] La clase ApplicationBootstrap ha sido definida.
// La aplicación se está iniciando...
En este ejemplo, Logger("APP_INIT")
es la llamada a la fábrica de decoradores. Devuelve la función del decorador real que toma target: Function
(el constructor de la clase) como su argumento. Esto permite la configuración dinámica del comportamiento del decorador.
Tipos de Decoradores en TypeScript
TypeScript admite cinco tipos distintos de decoradores, cada uno aplicable a un tipo específico de declaración. La firma de la función del decorador varía según el contexto en el que se aplica.
1. Decoradores de Clase
Los decoradores de clase se aplican a las declaraciones de clase. La función del decorador recibe el constructor de la clase como su único argumento. Un decorador de clase puede observar, modificar o incluso reemplazar la definición de una clase.
Firma:
function DecoradorDeClase(target: Function) { ... }
Valor de Retorno:
Si el decorador de clase devuelve un valor, reemplazará la declaración de la clase con la función constructora proporcionada. Esta es una característica poderosa, a menudo utilizada para mixins o para aumentar la clase. Si no se devuelve ningún valor, se utiliza la clase original.
Casos de Uso:
- Registrar clases en un contenedor de inyección de dependencias.
- Aplicar mixins o funcionalidades adicionales a una clase.
- Configuraciones específicas de un framework (por ejemplo, enrutamiento en un framework web).
- Añadir hooks de ciclo de vida a las clases.
Ejemplo de Decorador de Clase: Inyectando un Servicio
Imagina un escenario simple de inyección de dependencias donde quieres marcar una clase como "inyectable" y opcionalmente proporcionar un nombre para ella en un contenedor.
const InjectableServiceRegistry = new Map<string, Function>();
function Injectable(name?: string) {
return function<T extends { new(...args: any[]): {} }>(constructor: T) {
const serviceName = name || constructor.name;
InjectableServiceRegistry.set(serviceName, constructor);
console.log(`Servicio registrado: ${serviceName}`);
// Opcionalmente, podrías devolver una nueva clase aquí para aumentar el comportamiento
return class extends constructor {
createdAt = new Date();
// Propiedades o métodos adicionales para todos los servicios inyectados
};
};
}
@Injectable("UserService")
class UserDataService {
getUsers() {
return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
}
}
@Injectable()
class ProductDataService {
getProducts() {
return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
}
}
console.log("--- Servicios Registrados ---");
console.log(Array.from(InjectableServiceRegistry.keys()));
const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
const userServiceInstance = new userServiceConstructor();
console.log("Usuarios:", userServiceInstance.getUsers());
// console.log("Servicio de Usuario Creado En:", userServiceInstance.createdAt); // Si se utiliza la clase devuelta
}
Este ejemplo demuestra cómo un decorador de clase puede registrar una clase e incluso modificar su constructor. El decorador Injectable
hace que la clase sea descubrible por un sistema teórico de inyección de dependencias.
2. Decoradores de Método
Los decoradores de método se aplican a las declaraciones de método. Reciben tres argumentos: el objeto de destino (para miembros estáticos, la función constructora; para miembros de instancia, el prototipo de la clase), el nombre del método y el descriptor de propiedad del método.
Firma:
function DecoradorDeMetodo(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Valor de Retorno:
Un decorador de método puede devolver un nuevo PropertyDescriptor
. Si lo hace, este descriptor se usará para definir el método. Esto te permite modificar o reemplazar la implementación del método original, lo que lo hace increíblemente potente para la AOP.
Casos de Uso:
- Registrar llamadas a métodos y sus argumentos/resultados.
- Almacenar en caché los resultados de los métodos para mejorar el rendimiento.
- Aplicar comprobaciones de autorización antes de la ejecución del método.
- Medir el tiempo de ejecución del método.
- Aplicar debouncing o throttling a las llamadas de método.
Ejemplo de Decorador de Método: Monitoreo de Rendimiento
Vamos a crear un decorador MeasurePerformance
para registrar el tiempo de ejecución de un método.
function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const start = process.hrtime.bigint();
const result = originalMethod.apply(this, args);
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1_000_000;
console.log(`El método "${propertyKey}" se ejecutó en ${duration.toFixed(2)} ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@MeasurePerformance
processData(data: number[]): number[] {
// Simula una operación compleja y que consume tiempo
for (let i = 0; i < 1_000_000; i++) {
Math.sin(i);
}
return data.map(n => n * 2);
}
@MeasurePerformance
fetchRemoteData(id: string): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Datos para ID: ${id}`);
}, 500);
});
}
}
const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));
El decorador MeasurePerformance
envuelve el método original con lógica de temporización, imprimiendo la duración de la ejecución sin saturar la lógica de negocio dentro del propio método. Este es un ejemplo clásico de Programación Orientada a Aspectos (AOP).
3. Decoradores de Accesor
Los decoradores de accesor se aplican a las declaraciones de accesor (get
y set
). Al igual que los decoradores de método, reciben el objeto de destino, el nombre del accesor y su descriptor de propiedad.
Firma:
function DecoradorDeAccesor(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }
Valor de Retorno:
Un decorador de accesor puede devolver un nuevo PropertyDescriptor
, que se utilizará para definir el accesor.
Casos de Uso:
- Validación al establecer una propiedad.
- Transformar un valor antes de que se establezca o después de que se recupere.
- Controlar los permisos de acceso para las propiedades.
Ejemplo de Decorador de Accesor: Getters en Caché
Vamos a crear un decorador que almacene en caché el resultado de un cálculo costoso de un getter.
function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalGetter = descriptor.get;
const cacheKey = `_cached_${String(propertyKey)}`;
if (originalGetter) {
descriptor.get = function() {
if (this[cacheKey] === undefined) {
console.log(`[Cache Miss] Calculando valor para ${String(propertyKey)}`);
this[cacheKey] = originalGetter.apply(this);
} else {
console.log(`[Cache Hit] Usando valor en caché para ${String(propertyKey)}`);
}
return this[cacheKey];
};
}
return descriptor;
}
class ReportGenerator {
private data: number[];
constructor(data: number[]) {
this.data = data;
}
// Simula un cálculo costoso
@CachedGetter
get expensiveSummary(): number {
console.log("Realizando cálculo de resumen costoso...");
return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
}
}
const generator = new ReportGenerator([10, 20, 30, 40, 50]);
console.log("Primer acceso:", generator.expensiveSummary);
console.log("Segundo acceso:", generator.expensiveSummary);
console.log("Tercer acceso:", generator.expensiveSummary);
Este decorador asegura que el cálculo del getter expensiveSummary
solo se ejecute una vez; las llamadas posteriores devuelven el valor en caché. Este patrón es muy útil para optimizar el rendimiento donde el acceso a una propiedad implica un cálculo pesado o llamadas externas.
4. Decoradores de Propiedad
Los decoradores de propiedad se aplican a las declaraciones de propiedad. Reciben dos argumentos: el objeto de destino (para miembros estáticos, la función constructora; para miembros de instancia, el prototipo de la clase) y el nombre de la propiedad.
Firma:
function DecoradorDePropiedad(target: Object, propertyKey: string | symbol) { ... }
Valor de Retorno:
Los decoradores de propiedad no pueden devolver ningún valor. Su uso principal es registrar metadatos sobre la propiedad. No pueden cambiar directamente el valor de la propiedad o su descriptor en el momento de la decoración, ya que el descriptor de una propiedad aún no está completamente definido cuando se ejecutan los decoradores de propiedad.
Casos de Uso:
- Registrar propiedades para serialización/deserialización.
- Aplicar reglas de validación a las propiedades.
- Establecer valores predeterminados o configuraciones para las propiedades.
- Mapeo de columnas en ORM (Object-Relational Mapping) (por ejemplo,
@Column()
en TypeORM).
Ejemplo de Decorador de Propiedad: Validación de Campo Requerido
Vamos a crear un decorador para marcar una propiedad como "requerida" y luego validarla en tiempo de ejecución.
interface ValidationRule {
property: string | symbol;
validate: (value: any) => boolean;
message: string;
}
const validationRules: Map<Function, ValidationRule[]> = new Map();
function Required(target: Object, propertyKey: string | symbol) {
const rules = validationRules.get(target.constructor) || [];
rules.push({
property: propertyKey,
validate: (value: any) => value !== null && value !== undefined && value !== "",
message: `${String(propertyKey)} es requerido.`
});
validationRules.set(target.constructor, rules);
}
function validate(instance: any): string[] {
const classRules = validationRules.get(instance.constructor) || [];
const errors: string[] = [];
for (const rule of classRules) {
if (!rule.validate(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
class UserProfile {
@Required
firstName: string;
@Required
lastName: string;
age?: number;
constructor(firstName: string, lastName: string, age?: number) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
const user1 = new UserProfile("John", "Doe", 30);
console.log("Errores de validación del Usuario 1:", validate(user1)); // []
const user2 = new UserProfile("", "Smith");
console.log("Errores de validación del Usuario 2:", validate(user2)); // ["firstName es requerido."]
const user3 = new UserProfile("Alice", "");
console.log("Errores de validación del Usuario 3:", validate(user3)); // ["lastName es requerido."]
El decorador Required
simplemente registra la regla de validación en un mapa central validationRules
. Una función separada validate
utiliza luego estos metadatos para verificar la instancia en tiempo de ejecución. Este patrón separa la lógica de validación de la definición de datos, haciéndola reutilizable y limpia.
5. Decoradores de Parámetro
Los decoradores de parámetro se aplican a los parámetros dentro de un constructor de clase o un método. Reciben tres argumentos: el objeto de destino (para miembros estáticos, la función constructora; para miembros de instancia, el prototipo de la clase), el nombre del método (o undefined
para parámetros de constructor) y el índice ordinal del parámetro en la lista de parámetros de la función.
Firma:
function DecoradorDeParametro(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }
Valor de Retorno:
Los decoradores de parámetro no pueden devolver ningún valor. Al igual que los decoradores de propiedad, su función principal es agregar metadatos sobre el parámetro.
Casos de Uso:
- Registrar tipos de parámetros para inyección de dependencias (por ejemplo,
@Inject()
en Angular). - Aplicar validación o transformación a parámetros específicos.
- Extraer metadatos sobre los parámetros de solicitud de API en frameworks web.
Ejemplo de Decorador de Parámetro: Inyectando Datos de Solicitud
Simulemos cómo un framework web podría usar decoradores de parámetro para inyectar datos específicos en un parámetro de método, como un ID de usuario de una solicitud.
interface ParameterMetadata {
index: number;
key: string | symbol;
resolver: (request: any) => any;
}
const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();
function RequestParam(paramName: string) {
return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
const targetKey = propertyKey || "constructor";
let methodResolvers = parameterResolvers.get(target.constructor);
if (!methodResolvers) {
methodResolvers = new Map();
parameterResolvers.set(target.constructor, methodResolvers);
}
const paramMetadata = methodResolvers.get(targetKey) || [];
paramMetadata.push({
index: parameterIndex,
key: targetKey,
resolver: (request: any) => request[paramName]
});
methodResolvers.set(targetKey, paramMetadata);
};
}
// Una función hipotética de un framework para invocar un método con parámetros resueltos
function executeWithParams(instance: any, methodName: string, request: any) {
const classResolvers = parameterResolvers.get(instance.constructor);
if (!classResolvers) {
return (instance[methodName] as Function).apply(instance, []);
}
const methodParamMetadata = classResolvers.get(methodName);
if (!methodParamMetadata) {
return (instance[methodName] as Function).apply(instance, []);
}
const args: any[] = Array(methodParamMetadata.length);
for (const meta of methodParamMetadata) {
args[meta.index] = meta.resolver(request);
}
return (instance[methodName] as Function).apply(instance, args);
}
class UserController {
getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
console.log(`Obteniendo usuario con ID: ${userId}, Token: ${authToken || "N/A"}`);
return { id: userId, name: "Jane Doe" };
}
deleteUser(@RequestParam("id") userId: string) {
console.log(`Eliminando usuario con ID: ${userId}`);
return { status: "deleted", id: userId };
}
}
const userController = new UserController();
// Simula una solicitud entrante
const mockRequest = {
id: "user123",
token: "abc-123",
someOtherProp: "xyz"
};
console.log("\n--- Ejecutando getUser ---");
executeWithParams(userController, "getUser", mockRequest);
console.log("\n--- Ejecutando deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });
Este ejemplo muestra cómo los decoradores de parámetro pueden recopilar información sobre los parámetros de método requeridos. Un framework puede luego usar estos metadatos recopilados para resolver e inyectar automáticamente los valores apropiados cuando se llama al método, simplificando significativamente la lógica del controlador o servicio.
Composición de Decoradores y Orden de Ejecución
Los decoradores se pueden aplicar en varias combinaciones, y comprender su orden de ejecución es crucial para predecir el comportamiento y evitar problemas inesperados.
Múltiples Decoradores en un Solo Objetivo
Cuando se aplican múltiples decoradores a una sola declaración (por ejemplo, una clase, método o propiedad), se ejecutan en un orden específico: de abajo hacia arriba, o de derecha a izquierda, para su evaluación. Sin embargo, sus resultados se aplican en el orden opuesto.
@DecoradorA
@DecoradorB
class MiClase {
// ...
}
Aquí, DecoradorB
se evaluará primero, luego DecoradorA
. Si modifican la clase (por ejemplo, devolviendo un nuevo constructor), la modificación de DecoradorA
envolverá o se aplicará sobre la modificación de DecoradorB
.
Ejemplo: Encadenamiento de Decoradores de Método
Considera dos decoradores de método: LogCall
y Authorization
.
function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG] Llamando a ${String(propertyKey)} con args:`, args);
const result = originalMethod.apply(this, args);
console.log(`[LOG] Método ${String(propertyKey)} devolvió:`, result);
return result;
};
return descriptor;
}
function Authorization(roles: string[]) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const currentUserRoles = ["admin"]; // Simula la obtención de roles del usuario actual
const authorized = roles.some(role => currentUserRoles.includes(role));
if (!authorized) {
console.warn(`[AUTH] Acceso denegado para ${String(propertyKey)}. Roles requeridos: ${roles.join(", ")}`);
throw new Error("Acceso no autorizado");
}
console.log(`[AUTH] Acceso concedido para ${String(propertyKey)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class SecureService {
@LogCall
@Authorization(["admin"])
deleteSensitiveData(id: string) {
console.log(`Eliminando datos sensibles para ID: ${id}`);
return `Datos con ID ${id} eliminados.`;
}
@Authorization(["user"])
@LogCall // Orden cambiado aquí
fetchPublicData(query: string) {
console.log(`Obteniendo datos públicos con consulta: ${query}`);
return `Datos públicos para la consulta: ${query}`;
}
}
const service = new SecureService();
try {
console.log("\n--- Llamando a deleteSensitiveData (Usuario Admin) ---");
service.deleteSensitiveData("record123");
} catch (error: any) {
console.error(error.message);
}
try {
console.log("\n--- Llamando a fetchPublicData (Usuario no Admin) ---");
// Simula un usuario no administrador intentando acceder a fetchPublicData que requiere el rol 'user'
const mockUserRoles = ["guest"]; // Esto fallará la autenticación
// Para que esto sea dinámico, necesitarías un sistema de DI o un contexto estático para los roles del usuario actual.
// Para simplificar, asumimos que el decorador Authorization tiene acceso al contexto del usuario actual.
// Ajustemos el decorador Authorization para que siempre asuma 'admin' para fines de demostración,
// para que la primera llamada tenga éxito y la segunda falle para mostrar diferentes caminos.
// Vuelve a ejecutar con el rol de usuario para que fetchPublicData tenga éxito.
// Imagina que currentUserRoles en Authorization se convierte en: ['user']
// Para este ejemplo, mantengámoslo simple y mostremos el efecto del orden.
service.fetchPublicData("término de búsqueda"); // Esto ejecutará Auth -> Log
} catch (error: any) {
console.error(error.message);
}
/* Salida esperada para deleteSensitiveData:
[AUTH] Acceso concedido para deleteSensitiveData
[LOG] Llamando a deleteSensitiveData con args: [ 'record123' ]
Eliminando datos sensibles para ID: record123
[LOG] Método deleteSensitiveData devolvió: Datos con ID record123 eliminados.
*/
/* Salida esperada para fetchPublicData (si el usuario tiene el rol 'user'):
[LOG] Llamando a fetchPublicData con args: [ 'término de búsqueda' ]
[AUTH] Acceso concedido para fetchPublicData
Obteniendo datos públicos con consulta: término de búsqueda
[LOG] Método fetchPublicData devolvió: Datos públicos para la consulta: término de búsqueda
*/
Observa el orden: para deleteSensitiveData
, Authorization
(abajo) se ejecuta primero, luego LogCall
(arriba) lo envuelve. La lógica interna de Authorization
se ejecuta primero. Para fetchPublicData
, LogCall
(abajo) se ejecuta primero, luego Authorization
(arriba) lo envuelve. Esto significa que el aspecto LogCall
estará fuera del aspecto Authorization
. Esta diferencia es crítica para los aspectos transversales como el registro de logs o el manejo de errores, donde el orden de ejecución puede impactar significativamente el comportamiento.
Orden de Ejecución para Diferentes Objetivos
Cuando una clase, sus miembros y parámetros tienen todos decoradores, el orden de ejecución está bien definido:
- Los Decoradores de Parámetro se aplican primero, para cada parámetro, comenzando desde el último parámetro hasta el primero.
- Luego, los Decoradores de Método, Accesor o Propiedad se aplican para cada miembro.
- Finalmente, los Decoradores de Clase se aplican a la clase misma.
Dentro de cada categoría, múltiples decoradores en el mismo objetivo se aplican de abajo hacia arriba (o de derecha a izquierda).
Ejemplo: Orden de Ejecución Completo
function log(message: string) {
return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
if (typeof descriptorOrIndex === 'number') {
console.log(`Decorador de Parámetro: ${message} en el parámetro #${descriptorOrIndex} de ${String(propertyKey || "constructor")}`);
} else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
console.log(`Decorador de Método/Accesor: ${message} en ${String(propertyKey)}`);
} else {
console.log(`Decorador de Propiedad: ${message} en ${String(propertyKey)}`);
}
} else {
console.log(`Decorador de Clase: ${message} en ${target.name}`);
}
return descriptorOrIndex; // Devuelve el descriptor para método/accesor, undefined para otros
};
}
@log("Nivel de Clase D")
@log("Nivel de Clase C")
class MyDecoratedClass {
@log("Propiedad Estática A")
static staticProp: string = "";
@log("Propiedad de Instancia B")
instanceProp: number = 0;
@log("Método D")
@log("Método C")
myMethod(
@log("Parámetro Z") paramZ: string,
@log("Parámetro Y") paramY: number
) {
console.log("Método myMethod ejecutado.");
}
@log("Getter/Setter F")
get myAccessor() {
return "";
}
set myAccessor(value: string) {
//...
}
constructor() {
console.log("Constructor ejecutado.");
}
}
new MyDecoratedClass();
// Llama al método para activar el decorador de método
new MyDecoratedClass().myMethod("hola", 123);
/* Orden de Salida Predicho (aproximado, dependiendo de la versión específica de TypeScript y la compilación):
Decorador de Parámetro: Parámetro Y en el parámetro #1 de myMethod
Decorador de Parámetro: Parámetro Z en el parámetro #0 de myMethod
Decorador de Propiedad: Propiedad Estática A en staticProp
Decorador de Propiedad: Propiedad de Instancia B en instanceProp
Decorador de Método/Accesor: Getter/Setter F en myAccessor
Decorador de Método/Accesor: Método C en myMethod
Decorador de Método/Accesor: Método D en myMethod
Decorador de Clase: Nivel de Clase C en MyDecoratedClass
Decorador de Clase: Nivel de Clase D en MyDecoratedClass
Constructor ejecutado.
Método myMethod ejecutado.
*/
El momento exacto del registro en la consola puede variar ligeramente según cuándo se invoque un constructor o método, pero el orden en que las funciones del decorador se ejecutan (y, por lo tanto, se aplican sus efectos secundarios o valores devueltos) sigue las reglas anteriores.
Aplicaciones Prácticas y Patrones de Diseño con Decoradores
Los decoradores, especialmente en conjunto con el polyfill reflect-metadata
, abren un nuevo ámbito de programación impulsada por metadatos. Esto permite patrones de diseño potentes que abstraen el código repetitivo y los aspectos transversales.
1. Inyección de Dependencias (DI)
Uno de los usos más prominentes de los decoradores es en los frameworks de Inyección de Dependencias (como @Injectable()
, @Component()
, etc. de Angular, o el uso extensivo de DI en NestJS). Los decoradores te permiten declarar dependencias directamente en constructores o propiedades, permitiendo que el framework instancie y proporcione automáticamente los servicios correctos.
Ejemplo: Inyección de Servicio Simplificada
import "reflect-metadata"; // Esencial para emitDecoratorMetadata
const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
};
}
function Inject(token: any) {
return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
existingInjections[parameterIndex] = token;
Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
};
}
class Container {
private static instances = new Map<any, any>();
static resolve<T>(target: { new (...args: any[]): T }): T {
if (Container.instances.has(target)) {
return Container.instances.get(target);
}
const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
if (!isInjectable) {
throw new Error(`La clase ${target.name} no está marcada como @Injectable.`);
}
// Obtener los tipos de los parámetros del constructor (requiere emitDecoratorMetadata)
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
const dependencies = paramTypes.map((paramType, index) => {
// Usa el token explícito de @Inject si se proporciona, de lo contrario, infiere el tipo
const token = explicitInjections[index] || paramType;
if (token === undefined) {
throw new Error(`No se puede resolver el parámetro en el índice ${index} para ${target.name}. Podría ser una dependencia circular o un tipo primitivo sin un @Inject explícito.`);
}
return Container.resolve(token);
});
const instance = new target(...dependencies);
Container.instances.set(target, instance);
return instance;
}
}
// Definir servicios
@Injectable()
class DatabaseService {
connect() {
console.log("Conectando a la base de datos...");
return "Conexión BD";
}
}
@Injectable()
class AuthService {
private db: DatabaseService;
constructor(db: DatabaseService) {
this.db = db;
}
login() {
console.log(`AuthService: Autenticando usando ${this.db.connect()}`);
return "Usuario autenticado";
}
}
@Injectable()
class UserService {
private authService: AuthService;
private dbService: DatabaseService; // Ejemplo de inyección a través de una propiedad usando un decorador personalizado o una característica del framework
constructor(@Inject(AuthService) authService: AuthService,
@Inject(DatabaseService) dbService: DatabaseService) {
this.authService = authService;
this.dbService = dbService;
}
getUserProfile() {
this.authService.login();
this.dbService.connect();
console.log("UserService: Obteniendo perfil de usuario...");
return { id: 1, name: "Usuario Global" };
}
}
// Resolver el servicio principal
console.log("--- Resolviendo UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());
console.log("\n--- Resolviendo AuthService (debería estar en caché) ---");
const authService = Container.resolve(AuthService);
authService.login();
Este ejemplo elaborado demuestra cómo los decoradores @Injectable
y @Inject
, combinados con reflect-metadata
, permiten que un Container
personalizado resuelva y proporcione dependencias automáticamente. Los metadatos design:paramtypes
emitidos automáticamente por TypeScript (cuando emitDecoratorMetadata
es true) son cruciales aquí.
2. Programación Orientada a Aspectos (AOP)
La AOP se enfoca en modularizar los aspectos transversales (por ejemplo, logging, seguridad, transacciones) que atraviesan múltiples clases y módulos. Los decoradores son una excelente opción para implementar conceptos de AOP en TypeScript.
Ejemplo: Logging con Decorador de Método
Volviendo al decorador LogCall
, es un ejemplo perfecto de AOP. Agrega comportamiento de logging a cualquier método sin modificar el código original del método. Esto separa el "qué hacer" (lógica de negocio) del "cómo hacerlo" (logging, monitoreo de rendimiento, etc.).
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[LOG AOP] Entrando al método: ${String(propertyKey)} con args:`, args);
try {
const result = originalMethod.apply(this, args);
console.log(`[LOG AOP] Saliendo del método: ${String(propertyKey)} con resultado:`, result);
return result;
} catch (error: any) {
console.error(`[LOG AOP] Error en el método ${String(propertyKey)}:`, error.message);
throw error;
}
};
return descriptor;
}
class PaymentProcessor {
@LogMethod
processPayment(amount: number, currency: string) {
if (amount <= 0) {
throw new Error("El monto del pago debe ser positivo.");
}
console.log(`Procesando pago de ${amount} ${currency}...`);
return `Pago de ${amount} ${currency} procesado exitosamente.`;
}
@LogMethod
refundPayment(transactionId: string) {
console.log(`Reembolsando pago para el ID de transacción: ${transactionId}...`);
return `Reembolso iniciado para ${transactionId}.`;
}
}
const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
processor.processPayment(-50, "EUR");
} catch (error: any) {
console.error("Error capturado:", error.message);
}
Este enfoque mantiene la clase PaymentProcessor
enfocada puramente en la lógica de pago, mientras que el decorador LogMethod
maneja el aspecto transversal del logging.
3. Validación y Transformación
Los decoradores son increíblemente útiles para definir reglas de validación directamente en las propiedades o para transformar datos durante la serialización/deserialización.
Ejemplo: Validación de Datos con Decoradores de Propiedad
El ejemplo de @Required
anterior ya demostró esto. Aquí hay otro ejemplo con una validación de rango numérico.
interface FieldValidationRule {
property: string | symbol;
validator: (value: any) => boolean;
message: string;
}
const fieldValidationRules = new Map<Function, FieldValidationRule[]>();
function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
const rules = fieldValidationRules.get(target.constructor) || [];
rules.push({ property: propertyKey, validator, message });
fieldValidationRules.set(target.constructor, rules);
}
function IsPositive(target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} debe ser un número positivo.`);
}
function MaxLength(maxLength: number) {
return function (target: Object, propertyKey: string | symbol) {
addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} debe tener como máximo ${maxLength} caracteres.`);
};
}
class Product {
@MaxLength(50)
name: string;
@IsPositive
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
static validate(instance: any): string[] {
const errors: string[] = [];
const rules = fieldValidationRules.get(instance.constructor) || [];
for (const rule of rules) {
if (!rule.validator(instance[rule.property])) {
errors.push(rule.message);
}
}
return errors;
}
}
const product1 = new Product("Laptop", 1200);
console.log("Errores del producto 1:", Product.validate(product1)); // []
const product2 = new Product("Nombre de producto muy largo que excede el límite de cincuenta caracteres para fines de prueba", 50);
console.log("Errores del producto 2:", Product.validate(product2)); // ["name debe tener como máximo 50 caracteres."]
const product3 = new Product("Libro", -10);
console.log("Errores del producto 3:", Product.validate(product3)); // ["price debe ser un número positivo."]
Esta configuración te permite definir declarativamente reglas de validación en las propiedades de tu modelo, haciendo que tus modelos de datos se autodescriban en términos de sus restricciones.
Mejores Prácticas y Consideraciones
Aunque los decoradores son potentes, deben usarse con prudencia. Un mal uso puede llevar a un código más difícil de depurar o entender.
Cuándo Usar Decoradores (y Cuándo No)
- Úsalos para:
- Aspectos transversales: Logging, caché, autorización, gestión de transacciones.
- Declaración de metadatos: Definir esquemas para ORMs, reglas de validación, configuración de DI.
- Integración con frameworks: Al construir o usar frameworks que aprovechan los metadatos.
- Reducir código repetitivo: Abstraer patrones de código repetitivos.
- Evítalos para:
- Llamadas a funciones simples: Si una simple llamada a una función puede lograr el mismo resultado de manera clara, prefiérela.
- Lógica de negocio: Los decoradores deben aumentar, no definir, la lógica de negocio principal.
- Complicación excesiva: Si usar un decorador hace que el código sea menos legible o más difícil de probar, reconsidéralo.
Implicaciones de Rendimiento
Los decoradores se ejecutan en tiempo de compilación (o en tiempo de definición en el entorno de ejecución de JavaScript si se transpila). La transformación o la recopilación de metadatos ocurre cuando se define la clase/método, no en cada llamada. Por lo tanto, el impacto en el rendimiento en tiempo de ejecución de *aplicar* decoradores es mínimo. Sin embargo, la *lógica dentro* de tus decoradores puede tener un impacto en el rendimiento, especialmente si realizan operaciones costosas en cada llamada a un método (por ejemplo, cálculos complejos dentro de un decorador de método).
Mantenibilidad y Legibilidad
Los decoradores, cuando se usan correctamente, pueden mejorar significativamente la legibilidad al mover el código repetitivo fuera de la lógica principal. Sin embargo, si realizan transformaciones complejas y ocultas, la depuración puede volverse un desafío. Asegúrate de que tus decoradores estén bien documentados y que su comportamiento sea predecible.
Estado Experimental y Futuro de los Decoradores
Es importante reiterar que los decoradores de TypeScript se basan en una propuesta de Etapa 3 del TC39. Esto significa que la especificación es en gran medida estable, pero aún podría sufrir cambios menores antes de convertirse en parte del estándar oficial de ECMAScript. Frameworks como Angular los han adoptado, apostando por su eventual estandarización. Esto implica un cierto nivel de riesgo, aunque dada su amplia adopción, es poco probable que haya cambios importantes que rompan la compatibilidad.
La propuesta del TC39 ha evolucionado. La implementación actual de TypeScript se basa en una versión anterior de la propuesta. Existe una distinción entre "Decoradores Heredados" (Legacy Decorators) y "Decoradores Estándar" (Standard Decorators). Cuando el estándar oficial llegue, es probable que TypeScript actualice su implementación. Para la mayoría de los desarrolladores que usan frameworks, esta transición será gestionada por el propio framework. Para los autores de bibliotecas, podría ser necesario comprender las sutiles diferencias entre los decoradores heredados y los futuros decoradores estándar.
La Opción del Compilador emitDecoratorMetadata
Esta opción, cuando se establece en true
en tsconfig.json
, instruye al compilador de TypeScript para que emita ciertos metadatos de tipo de tiempo de diseño en el JavaScript compilado. Estos metadatos incluyen el tipo de los parámetros del constructor (design:paramtypes
), el tipo de retorno de los métodos (design:returntype
) y el tipo de las propiedades (design:type
).
Estos metadatos emitidos no son parte del entorno de ejecución estándar de JavaScript. Típicamente son consumidos por el polyfill reflect-metadata
, que luego los hace accesibles a través de las funciones Reflect.getMetadata()
. Esto es absolutamente crítico para patrones avanzados como la Inyección de Dependencias, donde un contenedor necesita saber los tipos de dependencias que una clase requiere sin una configuración explícita.
Patrones Avanzados con Decoradores
Los decoradores se pueden combinar y extender para construir patrones aún más sofisticados.
1. Decorando Decoradores (Decoradores de Orden Superior)
Puedes crear decoradores que modifiquen o compongan otros decoradores. Esto es menos común pero demuestra la naturaleza funcional de los decoradores.
// Un decorador que asegura que un método se registre y también requiera roles de administrador
function AdminAndLoggedMethod() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Aplicar Authorization primero (interno)
Authorization(["admin"])(target, propertyKey, descriptor);
// Luego aplicar LogCall (externo)
LogCall(target, propertyKey, descriptor);
return descriptor; // Devuelve el descriptor modificado
};
}
class AdminPanel {
@AdminAndLoggedMethod()
deleteUserAccount(userId: string) {
console.log(`Eliminando cuenta de usuario: ${userId}`);
return `Usuario ${userId} eliminado.`;
}
}
const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Salida Esperada (asumiendo rol de admin):
[AUTH] Acceso concedido para deleteUserAccount
[LOG] Llamando a deleteUserAccount con args: [ 'user007' ]
Eliminando cuenta de usuario: user007
[LOG] Método deleteUserAccount devolvió: Usuario user007 eliminado.
*/
Aquí, AdminAndLoggedMethod
es una fábrica que devuelve un decorador, y dentro de ese decorador, aplica otros dos decoradores. Este patrón puede encapsular composiciones complejas de decoradores.
2. Usando Decoradores para Mixins
Aunque TypeScript ofrece otras formas de implementar mixins, los decoradores se pueden usar para inyectar capacidades en las clases de forma declarativa.
function ApplyMixins(constructors: Function[]) {
return function (derivedConstructor: Function) {
constructors.forEach(baseConstructor => {
Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
Object.defineProperty(
derivedConstructor.prototype,
name,
Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
);
});
});
};
}
class Disposable {
isDisposed: boolean = false;
dispose() {
this.isDisposed = true;
console.log("Objeto desechado.");
}
}
class Loggable {
log(message: string) {
console.log(`[Loggable] ${message}`);
}
}
@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
// Estas propiedades/métodos son inyectados por el decorador
isDisposed!: boolean;
dispose!: () => void;
log!: (message: string) => void;
constructor(public name: string) {
this.log(`Recurso ${this.name} creado.`);
}
cleanUp() {
this.dispose();
this.log(`Recurso ${this.name} limpiado.`);
}
}
const resource = new MyResource("NetworkConnection");
console.log(`Está desechado: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Está desechado: ${resource.isDisposed}`);
Este decorador @ApplyMixins
copia dinámicamente métodos y propiedades de los constructores base al prototipo de la clase derivada, "mezclando" eficazmente funcionalidades.
Conclusión: Potenciando el Desarrollo Moderno con TypeScript
Los decoradores de TypeScript son una característica potente y expresiva que habilita un nuevo paradigma de programación orientada a aspectos e impulsada por metadatos. Permiten a los desarrolladores mejorar, modificar y agregar comportamientos declarativos a clases, métodos, propiedades, accesores y parámetros sin alterar su lógica principal. Esta separación de intereses conduce a un código más limpio, más mantenible y altamente reutilizable.
Desde simplificar la inyección de dependencias e implementar sistemas de validación robustos hasta agregar aspectos transversales como el registro y el monitoreo del rendimiento, los decoradores proporcionan una solución elegante a muchos desafíos comunes del desarrollo. Si bien su estado experimental justifica estar al tanto, su amplia adopción en los principales frameworks significa su valor práctico y su relevancia futura.
Al dominar los decoradores de TypeScript, obtienes una herramienta significativa en tu arsenal, que te permite construir aplicaciones más robustas, escalables e inteligentes. Adóptalos de manera responsable, comprende su mecánica y desbloquea un nuevo nivel de poder declarativo en tus proyectos de TypeScript.