Decoradores en TypeScript: del modo experimental al estándar TC39 en 2026

Los decoradores llevan años en TypeScript, pero hay dos versiones y no son compatibles entre sí. Si has trabajado con Angular o NestJS, conoces la antigua. Si empiezas un proyecto nuevo con TypeScript 5.x, tienes que saber cuál usar. No elegir bien te va a costar tiempo.

Dos propuestas, dos APIs distintas

Durante años, TypeScript implementó una versión propia de decoradores bajo la bandera experimentalDecorators: true en el tsconfig.json. Era una propuesta temprana, nunca estandarizada, que Angular adoptó para sus @Component y @NgModule, y NestJS para sus @Controller y @Injectable.

El problema es que esa propuesta nunca llegó a ser un estándar. TC39 la reformuló desde cero y publicó una nueva propuesta que alcanzó Stage 3 en 2022. TypeScript 5.0, lanzado en marzo de 2023, la implementó. Desde entonces tienes dos opciones:

  • Decoradores experimentales: activos cuando tienes "experimentalDecorators": true en el tsconfig. Son los que usa Angular (hasta las versiones recientes), NestJS y muchas librerías legacy. Su firma es (target, key, descriptor).
  • Decoradores TC39: el nuevo estándar. Disponibles en TypeScript 5.0+ sin ningún flag especial. Su firma es (value, context).

No son compatibles. Un decorador escrito para la API experimental no funciona con la nueva, y viceversa. Si activas experimentalDecorators: true, TypeScript usa la antigua; si lo dejas desactivado (o lo omites en un proyecto nuevo con TS 5+), usa la TC39.

La nueva API de decoradores TC39

La diferencia más visible es la firma. En los decoradores TC39, cualquier decorador recibe dos argumentos: el valor decorado y un objeto context que describe qué se está decorando.

function miDecorador(value: any, context: DecoratorContext) {
  console.log(context.kind);  // 'class', 'method', 'field', 'accessor', 'getter', 'setter'
  console.log(context.name);  // nombre del elemento decorado
}

El objeto context tiene propiedades útiles:

  • context.kind: qué tipo de elemento se está decorando.
  • context.name: el nombre (como string o symbol).
  • context.static: si el miembro es estático.
  • context.private: si el miembro es privado.
  • context.addInitializer(fn): registra una función que se ejecutará cuando la clase se instancie.

Decorador de clase

Un decorador de clase recibe la propia clase como primer argumento y puede devolver una nueva clase que la envuelva o extienda.

function sealed(target: typeof MyClass, context: ClassDecoratorContext) {
  Object.seal(target.prototype);
}

@sealed
class MyClass {
  nombre = 'ejemplo';
  saludar() { return `Hola, ${this.nombre}`; }
}

Si devuelves una clase desde el decorador, TypeScript la usa en lugar de la original. Esto te permite inyectar comportamiento sin tocar el código de la clase.

function conTimestamp(
  target: T,
  context: ClassDecoratorContext
) {
  return class extends target {
    creadoEn = new Date();
  };
}

Decorador de método

Aquí es donde más partido se le saca. El decorador recibe la función original y puede devolver otra que la envuelva.

function log(value: Function, context: ClassMethodDecoratorContext) {
  return function(this: any, ...args: any[]) {
    console.log(`Llamando a ${String(context.name)} con`, args);
    const resultado = value.apply(this, args);
    console.log(`${String(context.name)} devuelve`, resultado);
    return resultado;
  };
}

class Calculadora {
  @log
  sumar(a: number, b: number) {
    return a + b;
  }
}

Otro caso habitual es el memoize: cachear el resultado de un método por sus argumentos para no recalcular lo mismo dos veces.

function memoize(value: Function, context: ClassMethodDecoratorContext) {
  const cache = new Map();
  return function(this: any, ...args: any[]) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const resultado = value.apply(this, args);
    cache.set(key, resultado);
    return resultado;
  };
}

Decorador de campo y @accessor

Los decoradores de campo son distintos a los de método. No reciben el valor inicial del campo (porque aún no existe en el momento de decorar), sino una función de inicialización.

function readonly(_: undefined, context: ClassFieldDecoratorContext) {
  return function(this: any, initialValue: any) {
    let value = initialValue;
    Object.defineProperty(this, context.name as string, {
      get() { return value; },
      set() { throw new Error(`${String(context.name)} es de solo lectura`); },
      enumerable: true,
      configurable: false,
    });
    return value;
  };
}

La palabra clave @accessor va un paso más allá: convierte un campo en un par getter/setter con almacenamiento privado automático, y te permite interceptarlo con un decorador.

class Temperatura {
  @accessor valor = 0;
}

// Con un decorador que valide el rango:
function rango(min: number, max: number) {
  return function(_: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext) {
    return {
      set(this: any, v: number) {
        if (v < min || v > max) throw new RangeError(`Fuera de rango: ${v}`);
        _.set.call(this, v);
      }
    };
  };
}

class Temperatura {
  @rango(0, 100) @accessor valor = 20;
}

La diferencia con el readonly de TypeScript es importante: el de TypeScript es solo en tiempo de compilación. El decorador actúa en tiempo de ejecución, así que protege el valor incluso en código JavaScript compilado.

context.addInitializer

addInitializer te permite registrar una función que se ejecuta cuando el constructor termina y todos los campos están inicializados. Es útil para registrar instancias en un registro global, suscribirse a eventos o inicializar observadores sin ensuciar el constructor.

const registro = new Map();

function registrar(_: any, context: ClassDecoratorContext) {
  context.addInitializer(function(this: any) {
    registro.set(String(context.name), this);
  });
}

@registrar
class ServicioEmail {
  enviar(msg: string) { console.log(msg); }
}

// Después de instanciar:
const s = new ServicioEmail();
console.log(registro.get('ServicioEmail')); // la instancia

El contexto de this dentro del inicializador es la instancia recién creada, así que puedes acceder a sus campos sin problema.

Activar los decoradores TC39 en TypeScript 5.x

La configuración es sencilla. Para usar la nueva API, no tienes que hacer nada especial en un proyecto nuevo con TypeScript 5.0 o superior. Basta con no tener experimentalDecorators: true.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true
    // Sin experimentalDecorators: TypeScript usa la API TC39
  }
}

Si tu proyecto viene de versiones anteriores y tiene "experimentalDecorators": true, estás usando la API antigua. Para migrar a TC39 tienes que:

  • Quitar "experimentalDecorators": true del tsconfig.
  • Reescribir cada decorador con la nueva firma (value, context).
  • Revisar las librerías que uses: si dependen de la API experimental, seguirán necesitando el flag.

El compilador te avisa si detecta mezcla de las dos APIs, así que es difícil que cometas un error silencioso.

Qué pasa con los frameworks en 2026

La situación varía bastante según el framework:

  • Angular 18+: está en proceso de migración a decoradores TC39. Las versiones recientes mantienen compatibilidad con los experimentales, pero la dirección es clara. Si empiezas un proyecto Angular nuevo, consulta la documentación de tu versión exacta.
  • NestJS: sigue usando la API experimental con experimentalDecorators: true. Sus decoradores (@Controller, @Get, @Injectable) no funcionan con la nueva API. Si usas NestJS, mantén el flag activado.
  • MikroORM y TypeORM: algunas versiones recientes ya soportan TC39. Revisa el changelog de tu versión antes de migrar.
  • Proyectos sin framework: usa TC39 directamente. No hay motivo para quedarse en la API experimental si no tienes dependencias que la requieran.

La regla práctica es simple: si usas un framework que impone experimentalDecorators, mantenlo. Si escribes código propio o el framework ya soporta TC39, usa la nueva API.

Un ejemplo completo con la API TC39

Para redondear, aquí tienes una clase con varios decoradores de la nueva API trabajando juntos:

// Decorador de método: mide el tiempo de ejecución
function cronometrar(value: Function, context: ClassMethodDecoratorContext) {
  return function(this: any, ...args: any[]) {
    const inicio = performance.now();
    const resultado = value.apply(this, args);
    const fin = performance.now();
    console.log(`${String(context.name)}: ${(fin - inicio).toFixed(2)}ms`);
    return resultado;
  };
}

// Decorador de clase: congela el prototipo
function inmutable(target: any, _context: ClassDecoratorContext) {
  Object.freeze(target.prototype);
}

@inmutable
class ProcesadorDatos {
  @cronometrar
  procesar(datos: number[]): number {
    return datos.reduce((acc, n) => acc + n, 0);
  }
}

const p = new ProcesadorDatos();
p.procesar([1, 2, 3, 4, 5]); // procesar: 0.03ms

Si quieres profundizar en las novedades de TypeScript 5.x más allá de los decoradores, tienes un buen punto de partida en TypeScript 5.x y los cambios más importantes. Y si te interesa el lado JavaScript de la propuesta, JavaScript y la propuesta de decoradores TC39 explica el contexto del estándar.

Imagen: Pexels / Christina Morillo

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP