Nederlands

Ontdek de kracht van TypeScript Decorators voor metadata-programmering, aspect-georiënteerd programmeren en het verbeteren van code met declaratieve patronen. Een complete gids voor ontwikkelaars wereldwijd.

TypeScript Decorators: Metadata-programmeerpatronen beheersen voor robuuste applicaties

In het uitgestrekte landschap van moderne softwareontwikkeling is het onderhouden van schone, schaalbare en beheersbare codebases van het grootste belang. TypeScript, met zijn krachtige typesysteem en geavanceerde functies, biedt ontwikkelaars de tools om dit te bereiken. Een van de meest intrigerende en transformerende functies zijn Decorators. Hoewel op het moment van schrijven nog een experimentele functie (Stage 3-voorstel voor ECMAScript), worden decorators veel gebruikt in frameworks zoals Angular en TypeORM, en veranderen ze fundamenteel hoe we ontwerppatronen, metadata-programmering en aspect-georiënteerd programmeren (AOP) benaderen.

Deze uitgebreide gids duikt diep in TypeScript decorators, waarbij hun werking, verschillende soorten, praktische toepassingen en best practices worden verkend. Of je nu grootschalige bedrijfsapplicaties, microservices of client-side webinterfaces bouwt, het begrijpen van decorators stelt je in staat om meer declaratieve, onderhoudbare en krachtige TypeScript-code te schrijven.

Het kernconcept begrijpen: Wat is een Decorator?

In de kern is een decorator een speciaal soort declaratie die kan worden gekoppeld aan een klassedeclaratie, methode, accessor, eigenschap of parameter. Decorators zijn functies die een nieuwe waarde retourneren (of een bestaande wijzigen) voor het doel dat ze decoreren. Hun primaire doel is om metadata toe te voegen of het gedrag van de declaratie waaraan ze zijn gekoppeld te veranderen, zonder de onderliggende codestructuur direct aan te passen. Deze externe, declaratieve manier om code uit te breiden is ongelooflijk krachtig.

Zie decorators als annotaties of labels die je op delen van je code toepast. Deze labels kunnen vervolgens worden gelezen of waarop kan worden gereageerd door andere delen van je applicatie of door frameworks, vaak tijdens runtime, om extra functionaliteit of configuratie te bieden.

De syntaxis van een Decorator

Decorators worden voorafgegaan door een @-symbool, gevolgd door de naam van de decorator-functie. Ze worden direct voor de declaratie geplaatst die ze decoreren.

@MyDecorator
class MyClass {
  @AnotherDecorator
  myMethod() {
    // ...
  }
}

Decorators inschakelen in TypeScript

Voordat je decorators kunt gebruiken, moet je de experimentalDecorators compiler-optie inschakelen in je tsconfig.json-bestand. Daarnaast heb je voor geavanceerde metadata-reflectiemogelijkheden (vaak gebruikt door frameworks) ook emitDecoratorMetadata en de reflect-metadata polyfill nodig.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Je moet ook reflect-metadata installeren:

npm install reflect-metadata --save
# of
yarn add reflect-metadata

En importeer het helemaal bovenaan het toegangspunt van je applicatie (bijv. main.ts of app.ts):

import "reflect-metadata";
// Je applicatiecode volgt hier

Decorator Factories: Maatwerk binnen handbereik

Hoewel een basisdecorator een functie is, moet je vaak argumenten doorgeven aan een decorator om het gedrag ervan te configureren. Dit wordt bereikt door een decorator factory te gebruiken. Een decorator factory is een functie die de eigenlijke decorator-functie retourneert. Wanneer je een decorator factory toepast, roep je deze aan met zijn argumenten, en retourneert deze vervolgens de decorator-functie die TypeScript op je code toepast.

Een eenvoudig voorbeeld van een Decorator Factory maken

Laten we een factory maken voor een Logger-decorator die berichten kan loggen met verschillende voorvoegsels.

function Logger(prefix: string) {
  return function (target: Function) {
    console.log(`[${prefix}] Klasse ${target.name} is gedefinieerd.`);
  };
}

@Logger("APP_INIT")
class ApplicationBootstrap {
  constructor() {
    console.log("Applicatie wordt gestart...");
  }
}

const app = new ApplicationBootstrap();
// Uitvoer:
// [APP_INIT] Klasse ApplicationBootstrap is gedefinieerd.
// Applicatie wordt gestart...

In dit voorbeeld is Logger("APP_INIT") de aanroep van de decorator factory. Het retourneert de eigenlijke decorator-functie die target: Function (de class constructor) als argument neemt. Dit maakt dynamische configuratie van het gedrag van de decorator mogelijk.

Soorten Decorators in TypeScript

TypeScript ondersteunt vijf verschillende soorten decorators, elk toepasbaar op een specifiek type declaratie. De signatuur van de decorator-functie varieert afhankelijk van de context waarin deze wordt toegepast.

1. Class Decorators

Class decorators worden toegepast op klassedeclaraties. De decorator-functie ontvangt de constructor van de klasse als enige argument. Een class decorator kan een klassedefinitie observeren, wijzigen of zelfs vervangen.

Signatuur:

function ClassDecorator(target: Function) { ... }

Retourwaarde:

Als de class decorator een waarde retourneert, zal deze de klassedeclaratie vervangen door de opgegeven constructor-functie. Dit is een krachtige functie, vaak gebruikt voor mixins of klassenuitbreiding. Als er geen waarde wordt geretourneerd, wordt de oorspronkelijke klasse gebruikt.

Toepassingen:

Voorbeeld van een Class Decorator: Een Service Injecteren

Stel je een eenvoudig scenario voor van dependency injection waarin je een klasse wilt markeren als "injectable" en optioneel een naam wilt opgeven in een container.

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(`Geregistreerde service: ${serviceName}`);

    // Optioneel kun je hier een nieuwe klasse retourneren om gedrag uit te breiden
    return class extends constructor {
      createdAt = new Date();
      // Extra eigenschappen of methoden voor alle geïnjecteerde services
    };
  };
}

@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("--- Services Geregistreerd ---");
console.log(Array.from(InjectableServiceRegistry.keys()));

const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
  const userServiceInstance = new userServiceConstructor();
  console.log("Gebruikers:", userServiceInstance.getUsers());
  // console.log("User Service Created At:", userServiceInstance.createdAt); // Als de geretourneerde klasse wordt gebruikt
}

Dit voorbeeld laat zien hoe een class decorator een klasse kan registreren en zelfs de constructor ervan kan wijzigen. De Injectable-decorator maakt de klasse vindbaar voor een theoretisch dependency injection-systeem.

2. Method Decorators

Method decorators worden toegepast op methodedeclaraties. Ze ontvangen drie argumenten: het doelobject (voor statische leden, de constructor-functie; voor instance-leden, het prototype van de klasse), de naam van de methode en de property descriptor van de methode.

Signatuur:

function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

Retourwaarde:

Een method decorator kan een nieuwe PropertyDescriptor retourneren. Als dat gebeurt, wordt deze descriptor gebruikt om de methode te definiëren. Dit stelt je in staat om de implementatie van de oorspronkelijke methode te wijzigen of te vervangen, wat het ongelooflijk krachtig maakt voor AOP.

Toepassingen:

Voorbeeld van een Method Decorator: Prestatiemonitoring

Laten we een MeasurePerformance-decorator maken om de uitvoeringstijd van een methode te loggen.

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(`Methode "${propertyKey}" uitgevoerd in ${duration.toFixed(2)} ms`);
    return result;
  };

  return descriptor;
}

class DataProcessor {
  @MeasurePerformance
  processData(data: number[]): number[] {
    // Simuleer een complexe, tijdrovende operatie
    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(`Data voor ID: ${id}`);
      }, 500);
    });
  }
}

const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));

De MeasurePerformance-decorator wikkelt de oorspronkelijke methode in timinglogica, en print de uitvoeringsduur zonder de bedrijfslogica binnen de methode zelf te vervuilen. Dit is een klassiek voorbeeld van Aspect-Georiënteerd Programmeren (AOP).

3. Accessor Decorators

Accessor decorators worden toegepast op accessor-declaraties (get en set). Net als method decorators ontvangen ze het doelobject, de naam van de accessor en de bijbehorende property descriptor.

Signatuur:

function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

Retourwaarde:

Een accessor decorator kan een nieuwe PropertyDescriptor retourneren, die zal worden gebruikt om de accessor te definiëren.

Toepassingen:

Voorbeeld van een Accessor Decorator: Getters Cachen

Laten we een decorator maken die het resultaat van een dure getter-berekening cachet.

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] Waarde berekenen voor ${String(propertyKey)}`);
        this[cacheKey] = originalGetter.apply(this);
      } else {
        console.log(`[Cache Hit] Gecachete waarde gebruiken voor ${String(propertyKey)}`);
      }
      return this[cacheKey];
    };
  }
  return descriptor;
}

class ReportGenerator {
  private data: number[];

  constructor(data: number[]) {
    this.data = data;
  }

  // Simuleert een dure berekening
  @CachedGetter
  get expensiveSummary(): number {
    console.log("Dure samenvattingsberekening uitvoeren...");
    return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
  }
}

const generator = new ReportGenerator([10, 20, 30, 40, 50]);

console.log("Eerste toegang:", generator.expensiveSummary);
console.log("Tweede toegang:", generator.expensiveSummary);
console.log("Derde toegang:", generator.expensiveSummary);

Deze decorator zorgt ervoor dat de berekening van de expensiveSummary-getter slechts één keer wordt uitgevoerd; volgende aanroepen retourneren de gecachete waarde. Dit patroon is zeer nuttig voor het optimaliseren van prestaties waarbij toegang tot een eigenschap zware berekeningen of externe aanroepen met zich meebrengt.

4. Property Decorators

Property decorators worden toegepast op eigenschapsdeclaraties. Ze ontvangen twee argumenten: het doelobject (voor statische leden, de constructor-functie; voor instance-leden, het prototype van de klasse), en de naam van de eigenschap.

Signatuur:

function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }

Retourwaarde:

Property decorators kunnen geen waarde retourneren. Hun primaire doel is het registreren van metadata over de eigenschap. Ze kunnen de waarde van de eigenschap of de descriptor ervan niet direct wijzigen op het moment van decoratie, omdat de descriptor voor een eigenschap nog niet volledig is gedefinieerd wanneer property decorators worden uitgevoerd.

Toepassingen:

Voorbeeld van een Property Decorator: Validatie van Verplichte Velden

Laten we een decorator maken om een eigenschap als "verplicht" te markeren en deze vervolgens tijdens runtime te valideren.

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)} is verplicht.`
  });
  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("Validatiefouten gebruiker 1:", validate(user1)); // []

const user2 = new UserProfile("", "Smith");
console.log("Validatiefouten gebruiker 2:", validate(user2)); // ["firstName is verplicht."]

const user3 = new UserProfile("Alice", "");
console.log("Validatiefouten gebruiker 3:", validate(user3)); // ["lastName is verplicht."]

De Required-decorator registreert eenvoudigweg de validatieregel in een centrale validationRules-map. Een aparte validate-functie gebruikt deze metadata vervolgens om de instantie tijdens runtime te controleren. Dit patroon scheidt validatielogica van de data-definitie, waardoor het herbruikbaar en schoon wordt.

5. Parameter Decorators

Parameter decorators worden toegepast op parameters binnen een klassenconstructor of een methode. Ze ontvangen drie argumenten: het doelobject (voor statische leden, de constructor-functie; voor instance-leden, het prototype van de klasse), de naam van de methode (of undefined voor constructor-parameters), en de ordinale index van de parameter in de parameterlijst van de functie.

Signatuur:

function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }

Retourwaarde:

Parameter decorators kunnen geen waarde retourneren. Net als property decorators is hun primaire rol het toevoegen van metadata over de parameter.

Toepassingen:

Voorbeeld van een Parameter Decorator: Request-data Injecteren

Laten we simuleren hoe een webframework parameter decorators zou kunnen gebruiken om specifieke data in een methodeparameter te injecteren, zoals een gebruikers-ID uit een request.

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);
  };
}

// Een hypothetische frameworkfunctie om een methode aan te roepen met opgeloste parameters
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(`Gebruiker ophalen met ID: ${userId}, Token: ${authToken || "N/B"}`);
    return { id: userId, name: "Jane Doe" };
  }

  deleteUser(@RequestParam("id") userId: string) {
    console.log(`Gebruiker verwijderen met ID: ${userId}`);
    return { status: "verwijderd", id: userId };
  }
}

const userController = new UserController();

// Simuleer een inkomende request
const mockRequest = {
  id: "user123",
  token: "abc-123",
  someOtherProp: "xyz"
};

console.log("
--- getUser uitvoeren ---");
executeWithParams(userController, "getUser", mockRequest);

console.log("
--- deleteUser uitvoeren ---");
executeWithParams(userController, "deleteUser", { id: "user456" });

Dit voorbeeld toont hoe parameter decorators informatie kunnen verzamelen over vereiste methodeparameters. Een framework kan deze verzamelde metadata vervolgens gebruiken om automatisch de juiste waarden op te lossen en te injecteren wanneer de methode wordt aangeroepen, wat de logica van controllers of services aanzienlijk vereenvoudigt.

Compositie en Uitvoeringsvolgorde van Decorators

Decorators kunnen in verschillende combinaties worden toegepast, en het begrijpen van hun uitvoeringsvolgorde is cruciaal voor het voorspellen van gedrag en het vermijden van onverwachte problemen.

Meerdere Decorators op één Doel

Wanneer meerdere decorators worden toegepast op één declaratie (bijv. een klasse, methode of eigenschap), worden ze in een specifieke volgorde uitgevoerd: van onder naar boven, of van rechts naar links, voor hun evaluatie. Hun resultaten worden echter in de omgekeerde volgorde toegepast.

@DecoratorA
@DecoratorB
class MyClass {
  // ...
}

Hier wordt DecoratorB als eerste geëvalueerd, daarna DecoratorA. Als ze de klasse wijzigen (bijv. door een nieuwe constructor te retourneren), zal de wijziging van DecoratorA de wijziging van DecoratorB omvatten of eroverheen worden toegepast.

Voorbeeld: Method Decorators Koppelen

Beschouw twee method decorators: LogCall en Authorization.

function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG] Aanroepen van ${String(propertyKey)} met args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[LOG] Methode ${String(propertyKey)} retourneerde:`, 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"]; // Simuleer het ophalen van de rollen van de huidige gebruiker
      const authorized = roles.some(role => currentUserRoles.includes(role));
      if (!authorized) {
        console.warn(`[AUTH] Toegang geweigerd voor ${String(propertyKey)}. Vereiste rollen: ${roles.join(", ")}`);
        throw new Error("Ongeautoriseerde toegang");
      }
      console.log(`[AUTH] Toegang verleend voor ${String(propertyKey)}`);
      return originalMethod.apply(this, args);
    };
    return descriptor;
  };
}

class SecureService {
  @LogCall
  @Authorization(["admin"])
  deleteSensitiveData(id: string) {
    console.log(`Gevoelige data verwijderen voor ID: ${id}`);
    return `Data ID ${id} verwijderd.`;
  }

  @Authorization(["user"])
  @LogCall // Volgorde hier gewijzigd
  fetchPublicData(query: string) {
    console.log(`Publieke data ophalen met query: ${query}`);
    return `Publieke data voor query: ${query}`; 
  }
}

const service = new SecureService();

try {
  console.log("
--- deleteSensitiveData aanroepen (Admin Gebruiker) ---");
  service.deleteSensitiveData("record123");
} catch (error: any) {
  console.error(error.message);
}

try {
  console.log("
--- fetchPublicData aanroepen (Niet-Admin Gebruiker) ---");
  // Simuleer een niet-admin gebruiker die fetchPublicData probeert te benaderen, wat de 'user'-rol vereist.
  const mockUserRoles = ["guest"]; // Dit zal de autorisatie laten mislukken
  // Om dit dynamisch te maken, heb je een DI-systeem of statische context nodig voor de rollen van de huidige gebruiker.
  // Voor de eenvoud gaan we ervan uit dat de Authorization-decorator toegang heeft tot de context van de huidige gebruiker.
  // Laten we de Authorization-decorator aanpassen om altijd 'admin' aan te nemen voor demodoelen, 
  // zodat de eerste aanroep slaagt en de tweede mislukt om verschillende paden te tonen.
  
  // Voer opnieuw uit met de gebruikersrol 'user' om fetchPublicData te laten slagen.
  // Stel je voor dat currentUserRoles in Authorization wordt: ['user']
  // Voor dit voorbeeld houden we het simpel en tonen we het effect van de volgorde.
  service.fetchPublicData("zoekterm"); // Dit zal Auth -> Log uitvoeren
} catch (error: any) {
  console.error(error.message);
}

/* Verwachte uitvoer voor deleteSensitiveData:
[AUTH] Toegang verleend voor deleteSensitiveData
[LOG] Aanroepen van deleteSensitiveData met args: [ 'record123' ]
Gevoelige data verwijderen voor ID: record123
[LOG] Methode deleteSensitiveData retourneerde: Data ID record123 verwijderd.
*/

/* Verwachte uitvoer voor fetchPublicData (als gebruiker de 'user'-rol heeft):
[LOG] Aanroepen van fetchPublicData met args: [ 'zoekterm' ]
[AUTH] Toegang verleend voor fetchPublicData
Publieke data ophalen met query: zoekterm
[LOG] Methode fetchPublicData retourneerde: Publieke data voor query: zoekterm
*/

Let op de volgorde: voor deleteSensitiveData wordt Authorization (onderaan) als eerste uitgevoerd, daarna wikkelt LogCall (bovenaan) eromheen. De interne logica van Authorization wordt als eerste uitgevoerd. Voor fetchPublicData wordt LogCall (onderaan) als eerste uitgevoerd, daarna wikkelt Authorization (bovenaan) eromheen. Dit betekent dat het LogCall-aspect buiten het Authorization-aspect valt. Dit verschil is cruciaal voor doorsnijdende zorgen zoals logging of foutafhandeling, waar de volgorde van uitvoering het gedrag aanzienlijk kan beïnvloeden.

Uitvoeringsvolgorde voor Verschillende Doelen

Wanneer een klasse, haar leden en parameters allemaal decorators hebben, is de uitvoeringsvolgorde goed gedefinieerd:

  1. Parameter Decorators worden als eerste toegepast, voor elke parameter, beginnend bij de laatste parameter tot de eerste.
  2. Vervolgens worden Method, Accessor of Property Decorators toegepast voor elk lid.
  3. Ten slotte worden Class Decorators toegepast op de klasse zelf.

Binnen elke categorie worden meerdere decorators op hetzelfde doel toegepast van onder naar boven (of van rechts naar links).

Voorbeeld: Volledige Uitvoeringsvolgorde

function log(message: string) {
  return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
    if (typeof descriptorOrIndex === 'number') {
      console.log(`Param Decorator: ${message} op parameter #${descriptorOrIndex} van ${String(propertyKey || "constructor")}`);
    } else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
      if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
        console.log(`Method/Accessor Decorator: ${message} op ${String(propertyKey)}`);
      } else {
        console.log(`Property Decorator: ${message} op ${String(propertyKey)}`);
      }
    } else {
      console.log(`Class Decorator: ${message} op ${target.name}`);
    }
    return descriptorOrIndex; // Retourneer descriptor voor methode/accessor, undefined voor anderen
  };
}

@log("Klasseniveau D")
@log("Klasseniveau C")
class MyDecoratedClass {
  @log("Statische Eigenschap A")
  static staticProp: string = "";

  @log("Instance Eigenschap B")
  instanceProp: number = 0;

  @log("Methode D")
  @log("Methode C")
  myMethod(
    @log("Parameter Z") paramZ: string,
    @log("Parameter Y") paramY: number
  ) {
    console.log("Methode myMethod uitgevoerd.");
  }

  @log("Getter/Setter F")
  get myAccessor() {
    return "";
  }

  set myAccessor(value: string) {
    //...
  }

  constructor() {
    console.log("Constructor uitgevoerd.");
  }
}

new MyDecoratedClass();
// Roep methode aan om method decorator te triggeren
new MyDecoratedClass().myMethod("hallo", 123);

/* Voorspelde Uitvoeringsvolgorde (bij benadering, afhankelijk van specifieke TypeScript-versie en compilatie):
Param Decorator: Parameter Y op parameter #1 van myMethod
Param Decorator: Parameter Z op parameter #0 van myMethod
Property Decorator: Statische Eigenschap A op staticProp
Property Decorator: Instance Eigenschap B op instanceProp
Method/Accessor Decorator: Getter/Setter F op myAccessor
Method/Accessor Decorator: Methode C op myMethod
Method/Accessor Decorator: Methode D op myMethod
Class Decorator: Klasseniveau C op MyDecoratedClass
Class Decorator: Klasseniveau D op MyDecoratedClass
Constructor uitgevoerd.
Methode myMethod uitgevoerd.
*/

De exacte timing van de consolelog kan enigszins variëren afhankelijk van wanneer een constructor of methode wordt aangeroepen, maar de volgorde waarin de decorator-functies zelf worden uitgevoerd (en dus hun neveneffecten of geretourneerde waarden worden toegepast) volgt de bovenstaande regels.

Praktische Toepassingen en Ontwerppatronen met Decorators

Decorators, vooral in combinatie met de reflect-metadata polyfill, openen een nieuw domein van metadata-gestuurd programmeren. Dit maakt krachtige ontwerppatronen mogelijk die boilerplate en doorsnijdende zorgen abstraheren.

1. Dependency Injection (DI)

Een van de meest prominente toepassingen van decorators is in Dependency Injection frameworks (zoals Angular's @Injectable(), @Component(), etc., of NestJS's uitgebreide gebruik van DI). Decorators stellen je in staat om afhankelijkheden direct op constructors of eigenschappen te declareren, waardoor het framework de juiste services automatisch kan instantiëren en aanbieden.

Voorbeeld: Vereenvoudigde Service-injectie

import "reflect-metadata"; // Essentieel voor 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(`Klasse ${target.name} is niet gemarkeerd als @Injectable.`);
    }

    // Haal de typen van de constructor-parameters op (vereist emitDecoratorMetadata)
    const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
    const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];

    const dependencies = paramTypes.map((paramType, index) => {
      // Gebruik expliciete @Inject-token indien aanwezig, anders het afgeleide type
      const token = explicitInjections[index] || paramType;
      if (token === undefined) {
        throw new Error(`Kan parameter op index ${index} voor ${target.name} niet oplossen. Mogelijk is het een circulaire afhankelijkheid of een primitief type zonder expliciete @Inject.`);
      }
      return Container.resolve(token);
    });

    const instance = new target(...dependencies);
    Container.instances.set(target, instance);
    return instance;
  }
}

// Definieer services
@Injectable()
class DatabaseService {
  connect() {
    console.log("Verbinding maken met de database...");
    return "DB Verbinding";
  }
}

@Injectable()
class AuthService {
  private db: DatabaseService;

  constructor(db: DatabaseService) {
    this.db = db;
  }

  login() {
    console.log(`AuthService: Authentiseren met ${this.db.connect()}`);
    return "Gebruiker ingelogd";
  }
}

@Injectable()
class UserService {
  private authService: AuthService;
  private dbService: DatabaseService; // Voorbeeld van injectie via eigenschap met een aangepaste decorator of frameworkfunctie

  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: Gebruikersprofiel ophalen...");
    return { id: 1, name: "Global User" };
  }
}

// Los de hoofdservice op
console.log("--- UserService oplossen ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());

console.log("
--- AuthService oplossen (zou gecachet moeten zijn) ---");
const authService = Container.resolve(AuthService);
authService.login();

Dit uitgebreide voorbeeld demonstreert hoe de @Injectable- en @Inject-decorators, in combinatie met reflect-metadata, een aangepaste Container in staat stellen om automatisch afhankelijkheden op te lossen en te voorzien. De design:paramtypes-metadata die automatisch door TypeScript wordt uitgezonden (wanneer emitDecoratorMetadata waar is) is hier cruciaal.

2. Aspect-Georiënteerd Programmeren (AOP)

AOP richt zich op het modulariseren van doorsnijdende zorgen (bijv. logging, beveiliging, transacties) die meerdere klassen en modules doorkruisen. Decorators zijn uitstekend geschikt voor het implementeren van AOP-concepten in TypeScript.

Voorbeeld: Loggen met een Method Decorator

Terugkomend op de LogCall-decorator, dit is een perfect voorbeeld van AOP. Het voegt loggedrag toe aan elke methode zonder de oorspronkelijke code van de methode te wijzigen. Dit scheidt het "wat te doen" (bedrijfslogica) van het "hoe het te doen" (loggen, prestatiemonitoring, enz.).

function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG AOP] Betreden van methode: ${String(propertyKey)} met args:`, args);
    try {
      const result = originalMethod.apply(this, args);
      console.log(`[LOG AOP] Verlaten van methode: ${String(propertyKey)} met resultaat:`, result);
      return result;
    } catch (error: any) {
      console.error(`[LOG AOP] Fout in methode ${String(propertyKey)}:`, error.message);
      throw error;
    }
  };
  return descriptor;
}

class PaymentProcessor {
  @LogMethod
  processPayment(amount: number, currency: string) {
    if (amount <= 0) {
      throw new Error("Betalingsbedrag moet positief zijn.");
    }
    console.log(`Betaling van ${amount} ${currency} verwerken...`);
    return `Betaling van ${amount} ${currency} succesvol verwerkt.`;
  }

  @LogMethod
  refundPayment(transactionId: string) {
    console.log(`Terugbetaling voor transactie-ID: ${transactionId} verwerken...`);
    return `Terugbetaling geïnitieerd voor ${transactionId}.`;
  }
}

const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
  processor.processPayment(-50, "EUR");
} catch (error: any) {
  console.error("Fout opgevangen:", error.message);
}

Deze aanpak houdt de PaymentProcessor-klasse puur gericht op betalingslogica, terwijl de LogMethod-decorator de doorsnijdende zorg van het loggen afhandelt.

3. Validatie en Transformatie

Decorators zijn ongelooflijk nuttig voor het definiëren van validatieregels direct op eigenschappen of voor het transformeren van data tijdens serialisatie/deserialisatie.

Voorbeeld: Datavalidatie met Property Decorators

Het @Required-voorbeeld eerder demonstreerde dit al. Hier is nog een voorbeeld met een numerieke bereikvalidatie.

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)} moet een positief getal zijn.`);
}

function MaxLength(maxLength: number) {
  return function (target: Object, propertyKey: string | symbol) {
    addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} mag maximaal ${maxLength} tekens lang zijn.`);
  };
}

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("Fouten product 1:", Product.validate(product1)); // []

const product2 = new Product("Een hele lange productnaam die de limiet van vijftig tekens overschrijdt voor testdoeleinden", 50);
console.log("Fouten product 2:", Product.validate(product2)); // ["name mag maximaal 50 tekens lang zijn."]

const product3 = new Product("Boek", -10);
console.log("Fouten product 3:", Product.validate(product3)); // ["price moet een positief getal zijn."]

Deze opzet stelt je in staat om validatieregels declaratief te definiëren op je modeleigenschappen, waardoor je datamodellen zelfbeschrijvend worden wat betreft hun beperkingen.

Best Practices en Overwegingen

Hoewel decorators krachtig zijn, moeten ze oordeelkundig worden gebruikt. Misbruik kan leiden tot code die moeilijker te debuggen of te begrijpen is.

Wanneer Decorators te Gebruiken (en wanneer niet)

Prestatie-implicaties

Decorators worden uitgevoerd tijdens het compileren (of op het moment van definitie in de JavaScript-runtime indien getranspileerd). De transformatie of het verzamelen van metadata gebeurt wanneer de klasse/methode wordt gedefinieerd, niet bij elke aanroep. Daarom is de impact op de runtime-prestaties van het *toepassen* van decorators minimaal. Echter, de *logica binnenin* je decorators kan een prestatie-impact hebben, vooral als ze dure operaties uitvoeren bij elke methode-aanroep (bijv. complexe berekeningen binnen een method decorator).

Onderhoudbaarheid en Leesbaarheid

Decorators kunnen, wanneer ze correct worden gebruikt, de leesbaarheid aanzienlijk verbeteren door boilerplate-code uit de hoofdlogica te verplaatsen. Als ze echter complexe, verborgen transformaties uitvoeren, kan debuggen een uitdaging worden. Zorg ervoor dat je decorators goed gedocumenteerd zijn en hun gedrag voorspelbaar is.

Experimentele Status en Toekomst van Decorators

Het is belangrijk om te herhalen dat TypeScript-decorators gebaseerd zijn op een Stage 3 TC39-voorstel. Dit betekent dat de specificatie grotendeels stabiel is, maar nog steeds kleine wijzigingen kan ondergaan voordat het onderdeel wordt van de officiële ECMAScript-standaard. Frameworks zoals Angular hebben ze omarmd, en gokken op hun uiteindelijke standaardisatie. Dit impliceert een zeker risico, hoewel gezien hun wijdverbreide adoptie, significante brekende wijzigingen onwaarschijnlijk zijn.

Het TC39-voorstel is geëvolueerd. De huidige implementatie van TypeScript is gebaseerd op een oudere versie van het voorstel. Er is een onderscheid tussen "Legacy Decorators" en "Standard Decorators". Wanneer de officiële standaard landt, zal TypeScript waarschijnlijk zijn implementatie bijwerken. Voor de meeste ontwikkelaars die frameworks gebruiken, zal deze overgang door het framework zelf worden beheerd. Voor bibliotheekschrijvers kan het nodig worden om de subtiele verschillen tussen legacy en toekomstige standaard decorators te begrijpen.

De emitDecoratorMetadata Compiler-optie

Deze optie, wanneer ingesteld op true in tsconfig.json, instrueert de TypeScript-compiler om bepaalde design-time type metadata uit te zenden naar de gecompileerde JavaScript. Deze metadata omvat het type van de constructorparameters (design:paramtypes), het retourtype van methoden (design:returntype), en het type van eigenschappen (design:type).

Deze uitgezonden metadata maakt geen deel uit van de standaard JavaScript-runtime. Het wordt doorgaans geconsumeerd door de reflect-metadata polyfill, die het vervolgens toegankelijk maakt via de Reflect.getMetadata() functies. Dit is absoluut cruciaal voor geavanceerde patronen zoals Dependency Injection, waarbij een container de typen van afhankelijkheden van een klasse moet kennen zonder expliciete configuratie.

Geavanceerde Patronen met Decorators

Decorators kunnen worden gecombineerd en uitgebreid om nog geavanceerdere patronen te bouwen.

1. Decorators Decoreren (Higher-Order Decorators)

Je kunt decorators maken die andere decorators wijzigen of samenstellen. Dit is minder gebruikelijk, maar toont de functionele aard van decorators aan.

// Een decorator die ervoor zorgt dat een methode wordt gelogd en ook admin-rollen vereist
function AdminAndLoggedMethod() {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Pas eerst Authorization toe (binnenste)
    Authorization(["admin"])(target, propertyKey, descriptor);
    // Pas daarna LogCall toe (buitenste)
    LogCall(target, propertyKey, descriptor);

    return descriptor; // Retourneer de gewijzigde descriptor
  };
}

class AdminPanel {
  @AdminAndLoggedMethod()
  deleteUserAccount(userId: string) {
    console.log(`Gebruikersaccount verwijderen: ${userId}`);
    return `Gebruiker ${userId} verwijderd.`;
  }
}

const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Verwachte Uitvoer (uitgaande van admin-rol):
[AUTH] Toegang verleend voor deleteUserAccount
[LOG] Aanroepen van deleteUserAccount met args: [ 'user007' ]
Gebruikersaccount verwijderen: user007
[LOG] Methode deleteUserAccount retourneerde: Gebruiker user007 verwijderd.
*/

Hier is AdminAndLoggedMethod een factory die een decorator retourneert, en binnen die decorator past het twee andere decorators toe. Dit patroon kan complexe decorator-composities inkapselen.

2. Decorators gebruiken voor Mixins

Hoewel TypeScript andere manieren biedt om mixins te implementeren, kunnen decorators worden gebruikt om functionaliteiten op een declaratieve manier in klassen te injecteren.

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("Object verwijderd.");
  }
}

class Loggable {
  log(message: string) {
    console.log(`[Loggable] ${message}`);
  }
}

@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
  // Deze eigenschappen/methoden worden geïnjecteerd door de decorator
  isDisposed!: boolean;
  dispose!: () => void;
  log!: (message: string) => void;

  constructor(public name: string) {
    this.log(`Resource ${this.name} aangemaakt.`);
  }

  cleanUp() {
    this.dispose();
    this.log(`Resource ${this.name} opgeruimd.`);
  }
}

const resource = new MyResource("NetworkConnection");
console.log(`Is verwijderd: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Is verwijderd: ${resource.isDisposed}`);

Deze @ApplyMixins-decorator kopieert dynamisch methoden en eigenschappen van basis-constructors naar het prototype van de afgeleide klasse, waardoor functionaliteiten effectief worden "ingemixt".

Conclusie: Moderne TypeScript-ontwikkeling versterken

TypeScript-decorators zijn een krachtige en expressieve functie die een nieuw paradigma van metadata-gestuurd en aspect-georiënteerd programmeren mogelijk maakt. Ze stellen ontwikkelaars in staat om declaratieve gedragingen toe te voegen aan, te wijzigen en te verbeteren voor klassen, methoden, eigenschappen, accessors en parameters zonder hun kernlogica te veranderen. Deze scheiding van zorgen leidt tot schonere, beter onderhoudbare en zeer herbruikbare code.

Van het vereenvoudigen van dependency injection en het implementeren van robuuste validatiesystemen tot het toevoegen van doorsnijdende zorgen zoals logging en prestatiemonitoring, decorators bieden een elegante oplossing voor veelvoorkomende ontwikkelingsuitdagingen. Hoewel hun experimentele status bewustzijn vereist, duidt hun wijdverbreide adoptie in grote frameworks op hun praktische waarde en toekomstige relevantie.

Door TypeScript-decorators te beheersen, krijg je een belangrijk hulpmiddel in je arsenaal, waardoor je robuustere, schaalbaardere en intelligentere applicaties kunt bouwen. Omarm ze op verantwoorde wijze, begrijp hun werking en ontgrendel een nieuw niveau van declaratieve kracht in je TypeScript-projecten.

TypeScript Decorators: Metadata-programmeerpatronen beheersen voor robuuste applicaties | MLOG