back to top

I Decorator in TypeScript

In questa lezione discuteremo di una delle funzionalità più interessanti di TypeScript. Si tratta dei Decorator che è possibile usare nella versione corrente di TypeScript compilando i file .ts con il flag –experimentalDecorators.

Al momento della stesura di questa guida, i Decorator non sono ancora presenti in Javascript, ma dovrebbero essere introdotti in una versione futura dato che si trovano già nella fase 2 del processo di standardizzazione. Trattandosi ancora di una funzionalità sperimentale, può darsi che vengano apportate delle modifiche nelle future versioni.

Cosa sono i Decorator

I Decorator sono delle funzioni che permettono di modificare o arricchire (decorare) una classe, i suoi membri oppure uno o più parametri di un determinato metodo attraverso un approccio dichiarativo e una particolare sintassi che li rende immediatamente riconoscibili.

Si tratta infatti di funzioni che vengono invocate anteponendo il simbolo ‘@’ al nome della funzione stessa immediatamente prima della definizione di una classe, metodo, proprietà o parametro. Esistono sostanzialmente quattro diversi tipi di funzione Decorator che presentano delle particolari segnature a seconda dell’elemento a cui saranno poi applicate. Avremo quindi quattro distinti modi di definire una funzione Decorator per i seguenti elementi:

  • metodi
  • proprietà
  • parametri
  • classi

Come abilitare il supporto ai Decorator in TypeScript

Per poter iniziare a usare i Decorator in TypeScript, bisogna abilitarli attraverso l’opzione experimentalDecorators da specificare via linea di comando oppure attraverso un file di configurazione tsconfig.json.

$ tsc --target ES5 --experimentalDecorators

In alternativa è possibile abilitare i Decorator tramite un file tsconfig.json

// Esempio semplificato di file tsconfig.json

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */

    /* Strict Type-Checking Options */
    "strict": true,                           /* Enable all strict type-checking options. */

    /* Experimental Options */
    "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
  }
}

Un esempio di Decorator per i metodi di una classe in TypeScript

Vediamo subito un esempio di funzione Decorator applicato a un metodo.

class Car {
  @capitalize
  modelloVettura(marca: string, modello: string) {
    return `${marca} - ${modello}`;
  }
}

Attraverso la sintassi @nome-decorator (@capitalize) abbiamo applicato la funzione Decorator al metodo modelloVettura(). A questo punto dobbiamo però definire la funzione Decorator capitalize(). Dal momento che viene applicata a un metodo di una classe dovrà avere una precisa segnatura come mostrato nel frammento di codice riportato sotto.

// definizione funzione Decorator capitalize()

function capitalize(target: any, propertyKey: string, descriptor: PropertyDescriptor): any {
  const oldFunction = descriptor.value;

  console.log('Decorator invocato in fase di definizione della classe')

  descriptor.value = function(...args: string[]) {
    const newArgs = args.map(
      arg => arg.charAt(0).toUpperCase() + arg.slice(1)
    );
    const returnValue = oldFunction.apply(target, newArgs);
    return `Nuovo valore restituito dopo aver applicato "@capitalize": ${returnValue}`;
  }

  return descriptor;
}

La funzione capitalize() presenta tre parametri:

  • target è la funzione costruttore della classe (Function constructor) per un membro statico o il prototype della classe per un metodo d istanza. (Car { modelloVettura: [Function] } nel nostro esempio)
  • propertyKey è il nome del metodo a cui @capitalize viene applicato; ("modelloVettura" nel nostro esempio)
  • descriptor è il descrittore della proprietà a cui viene applicato il Decorator. Si tratta di un particolare oggetto che descrive e definisce un membro di una classe e che può essere configurato in Javascript attraverso il metodo statico Object.defineProperty()

Soffermiamoci un momento ad analizzare il descrittore del metodo car.modelloVettura().

// property descriptor

{ 
  value: function(marca, modello) {
    return marca + " - " + modello;
  },  
  writable: true,
  enumerable: true,
  configurable: true 
}

Si tratta di un oggetto con quattro importanti proprietà:

  • configurable specifica se la proprietà (car.modelloVettura() in questo caso) può essere rimossa dall’oggetto (car) e se è possibile configurare le altre proprietà del descrittore.
  • writable indica se è possibile riassegnare alla proprietà un nuovo valore.
  • enumerable stabilisce se la proprietà è enumerabile, ovvero se deve essere presente o meno nel ciclo for..in e nel valore restituito da Object.keys()
  • value rappresenta il valore associato alla proprietà. In questo caso si tratta del corpo della funzione car.modelloVettura() da noi definita.

Se compiliamo il codice visto nell’esempio, verrà generato il codice Javascript corrispondente che consente di capire per quale motivo capitalize() è una funzione con i tre specifici parametri visti in precedenza. Infatti, se commentiamo per un attimo la parte di codice in cui applichiamo la funzione Decorator e generiamo il codice Javascript, visualizzeremo semplicemente il codice della funzione costruttore Car racchiuso in una IIFE (Immediately Invoked Function Expression).

var Car = (function () {
  function Car() {}
  // @capitalize
  Car.prototype.modelloVettura = function (marca, modello) {
    return marca + " - " + modello;
  };
  return Car;
}());

Al contrario con la funzione Decorator @capitalize, verrà aggiunta nella IIFE la chiamata a una funzione _decorate a cui viene passato come primo argomento un array di funzioni Decorator che devono essere applicate a un certo membro di Car. Il secondo argomento è Car.prototype che verrà poi passato alla funzione capitalize() da noi definita come primo argomento, ovvero come target. Il terzo argomento della funzione __decorate() è il nome della proprietà a cui deve essere applicata la lista di funzioni decorator. Nell’esempio sarà la proprietà modelloVettura. L’ultimo argomento è il descrittore di modelloVettura che sarà passato come ultimo argomento alla nostra funzione decorator capitalize(). Notate bene che la funzione capitalize() sarà invocata nella dichiarazione della classe Car e non quando istanziamo un oggetto di tipo Car.

var Car = (function () {
  function Car() {}

  Car.prototype.modelloVettura = function (marca, modello) {
    return marca + " - " + modello;
  };

  // semplificazione del codice realmente generato da TypeScript
  __decorate(
    [capitalize], 
    Car.prototype, 
    "modelloVettura", 
    Object.getOwnPropertyDescriptor(Car.prototype, "modelloVettura")
  );

  return Car;
}());

La funzione _decorate() può essere semplificata come mostrato nel frammento di codice riportato sotto.

var __decorate = function (decorators, target, key, descriptor) {
    var returnValue;
    for (var i = decorators.length - 1; i >= 0; i--) {
        returnValue = decorators[i](target, key, descriptor)
    }

    if (returnValue) {
        Object.defineProperty(target, key, returnValue);
    } 

    return returnValue;
};

In sostanza viene invocato ogni decorator presente nell’array decorators partendo dall’ultimo elemento dell’array. Se alla fine del ciclo ‘for’ la variabile returnValue (il valore di ritorno dell’ultimo decorator eseguito) non è null o undefined, viene invocato il metodo Object.defineProperty() sul metodo a cui era stato applicato il decorator. Se returnValue è un oggetto valido che può essere passato al metodo Object.defineProperty(), viene sovrascritto il descrittore del metodo a cui è stato applicato il decorator.

Tornando alla nostra funzione capitalize ci limitiamo a modificare la proprietà value del descrittore, ovvero a sovrascrivere il metodo modelloVettura. All’interno della nuova versione di modelloVettura rendiamo maiuscola la prima lettera di ogni argomento ricevuto e invochiamo la vecchia versione di modelloVettura che restituisce una stringa contenente la marca e il modello della vettura. Il risultato ottenuto viene restituito dopo esser stato concatenato alla stringa ‘Nuovo valore restituito dopo aver applicato "@capitalize"’.

Se istanziamo un nuovo oggetto di tipo Car otterremo un risultato simile a quello mostrato sotto.

const car = new Car();

console.log(car.modelloVettura('pagani', 'zonda'));
Decorator invocato in fase di definizione della classe
Nuovo valore restituito dopo aver applicato "@capitalize": Pagani - Zonda

Altri tipi di Decorator

Analizziamo quindi quali altri tipi di Decorator sono disponibili in TypeScript.

Decorator per le proprietà di una classe

Cominciamo con i Decorator per le proprietà di una classe. Vediamo un semplice esempio in cui creiamo un Decorator validate() che controlla se il valore assegnato alla proprietà car.plate è valido. Il decorator validate, essendo applicato a una proprietà, presenta solo due parametri. Anche in questo caso modifichiamo il descrittore della proprietà in modo da verificare che la targa sia valida ogni volta che viene assegnato un nuovo valore. In caso contrario viene stampato un messaggio di errore nella console.

function validate(target: any, propertyKey: string) {
  let value = target[propertyKey];
  Object.defineProperty(target, propertyKey, {
    get: () => value,
    set: (newValue) => {
      const pattern = /^[A-Z]{2}\s?[0-9]{3}\s?[A-Z]{2}$/;
      if (pattern.test(newValue)) {
        value = newValue;
      } else {
        console.error('Formato targa non valido');
      }
    }
  })
}

class Car {
  @validate
  plate: string;

  constructor(plate: string) {
    this.plate = plate;
  }
}

const plate = 'IT 000 UE';

const car = new Car(plate); 

console.log(car.plate); // IT 000 UE

car.plate = 'IT 101 UE';

console.log(car.plate); // IT 101 UE

// Messaggio d'errore nella console: Formato targa non valido
car.plate = 'IT 1010 UE';

console.log(car.plate); // IT 101 UE

Decorator per i parametri di un metodo

Un Decorator per i parametri di un metodo accetta sempre tre argomenti che sono:

  • la funzione costruttore della classe (Function constructor) per un membro statico o il prototype della classe per un metodo d istanza.
  • il nome del metodo
  • l’indice del parametro passato partendo dal valore zero
function paramDetails(target: any, propertyKey: string, parameterIndex: number) {
  console.log('Target: ',target);
  console.log('PropertyKey: ' + propertyKey);
  console.log('Parameter index: ' + parameterIndex);
}

class Car {
  color: string | undefined;

  paint(@paramDetails color: string) {
    this.color = color;
  }
}

Inserendo il frammento di codice riportato sopra in un nuovo file ed eseguendo il file .js, risultato della compilazione, otteniamo un output simile a quello mostrato sotto.

$ tsc -p . && node app.js
Target:  Car { paint: [Function] }
PropertyKey: paint
Parameter index: 0

Decorator per un’intera classe

È possibile creare una funzione Decorator per un’intera classe. Tale funzione sarà applicata al costruttore della classe e può essere usata per modificare, arricchire o sostiituire interamente la classe a cui viene applicata. In questo caso dovremo creare una funzione che accetta in ingresso una funzione costruttore e restituisce una nuova funzione costruttore.

interface AutonomousCar {
  autoPilot: boolean | undefined;
  startAutoPilot(): void;
  stopAutoPilot(): void;
}

interface Constructor<T> {
  new(...args:any[]):T
}

function addFeature<T extends Constructor<Car>>(constructor:T) {
  return class extends constructor implements AutonomousCar {
    autoPilot: boolean | undefined;
    startAutoPilot(): void{
      this.autoPilot = true;
      console.log('Autopilot ON');
    } 
    stopAutoPilot(): void{
      this.autoPilot = false;
      console.log('Autopilot OFF');
    } 
  }
}

@addFeature
class Car {
  manufacturer: string;
  model: string;

  constructor(manufacturer: string, model: string) {
    this.manufacturer = manufacturer;
    this.model = model;
  }

  details(): void {
    console.log(`${this.manufacturer} - ${this.model}`);
  }
}

const car = <Car & AutonomousCar>new Car('Tesla', 'Model 3');

car.details(); // Tesla - Model 3
car.startAutoPilot(); // Autopilot ON
car.stopAutoPilot(); // // Autopilot OFF

console.log(car.autoPilot); // false

Nell’esempio abbiamo definito una classe Car che ha il solo metodo car.details(). Attraverso la funzione Decorator addFeature() aggiungiamo delle nuove funzionalità alla classe Car. La funzione Decorator addFeature() riceve in ingresso una funzione costruttore e restituisce una nuova classe che estende Car aggiungendo una proprietà autoPilot e i due metodi startAutoPilot() e stopAutoPilot(). Quando abbiamo istanziato un nuovo oggetto di tipo Car, abbiamo usato il tipo intersezione (<Car & AutonomousCar>) che permette di combinare tipi multipli in un unico nuovo tipo. In questo modo possiamo accedere ai metodi presenti nella classe originale e a quelli aggiunti con la funzione Decorator.

Decorator Factory

A volte può essere utile definire una funzione Decorator che possa essere configurata in base a dei parametri ricevuti in ingresso. Ciò è possibile attraverso quella che prende il nome di Decorator Factory. Si tratta di una funzione che può ricevere qualsiasi argomento in ingresso e restituisce una funzione Decorator con una specifica segnatura, come abbiamo visto negli esempi illustrati finora.

Riprendiamo quindi l’esempio visto prima per validare il formato di una targa.

function validatePlate(pattern: RegExp) {
  return function (target: {[index: string]: any}, propertyKey: string) {
    let value = target[propertyKey];
    Object.defineProperty(target, propertyKey, {
      get: () => value,
      set: (newValue) => {
        if (pattern.test(newValue)) {
          value = newValue;
        } else {
          console.error('Formato targa non valido: ' + newValue);
        }
      }
    })
  }
}

class Car {
  @validatePlate(/^[A-Z]{2}\s?[0-9]{9}$/)
  plate: string;
  color: string | undefined;

  constructor(plate: string) {
    this.plate = plate;
  }
}

const plate = 'IT 123456789';

const car = new Car(plate); 

console.log('Targa corrente: ' + car.plate); // Targa corrente: IT 123456789

// Messaggio d'errore nella console -> Formato targa non valido: IT 101 UE
car.plate = 'IT 101 UE';

console.log('Targa corrente: ' + car.plate); // Targa corrente: IT 123456789

// Messaggio d'errore nella console -> Formato targa non valido: IT 1010 UE
car.plate = 'IT 1010 UE';

console.log('Targa corrente: ' + car.plate); // Targa corrente: IT 123456789

In questo caso creiamo però una nuova funzione validatePlate() che riceve in ingresso un’espressione regolare e restituisce una funzione Decorator in cui viene usata proprio l’espressione regolare per verificare la correttezza del formato di una targa. Notate che abbiamo invocato @validatePlate(/^[A-Z]{2}\s?[0-9]{9}$/) prima della proprietà car.plate passando un’espressione regolare come unico argomento. Nell’esempio illustrato vogliamo accettare solo targhe nel formato ‘AA 123456789’ (due lettere seguite da uno spazio opzionale e nove numeri).

Composizione di funzioni Decorator

È infine possibile applicare più di una funzione Decorator. Vediamo un semplice esempio per capire in che ordine vengono eseguite le funzioni Decorator.

function factoryDecoratorOne() {
  console.log('factoryDecoratorOne invocata');
  return function (target: {[index: string]: any}, propertyKey: string) {
    console.log('funzione Decorator 1 invocata');
    console.log('*****************************');

  }
}

function factoryDecoratorTwo() {
  console.log('factoryDecoratorTwo invocata');
  return function (target: {[index: string]: any}, propertyKey: string) {
    console.log('funzione Decorator 2 invocata');
    let value = target[propertyKey]
    Object.defineProperty(target, propertyKey, {
      get: () => value,
      set: (newValue: string) => {
        value = newValue.toUpperCase();
      }
    });
  }
}

class Car {
  @factoryDecoratorOne()
  @factoryDecoratorTwo()
  color: string;
  constructor(color: string) {
    this.color = color;
  }
}

const car = new Car('rosso');

console.log(car.color);

Eseguendo il codice, visualizzeremo un risultato simile a quello riportato sotto.

factoryDecoratorOne invocata
factoryDecoratorTwo invocata
funzione Decorator 2 invocata
funzione Decorator 1 invocata
*****************************
ROSSO

Le funzioni factoryDecoratorOne e factoryDecoratorTwo sono eseguite nell’ordine in cui vengono invocate. Le funzioni, da loro restituite, vengono invece invocate in ordine inverso.

Conclusioni

In questa lezione abbiamo illustrato una delle funzionalità più interessanti di TypeScript, ovvero i Decorator, che è possibile utilizzare già oggi anche se sono ancora in fase sperimentale e la relativa sintassi potrebbe subire delle modifiche in futuro. Nella prossima e ultima lezione discuteremo dei namespace e dei moduli che consentono di strutturare e organizzare le applicazioni realizzate in TypeScript.

Pubblicitร 
Articolo precedente
Articolo successivo