TypeScript tutorial: Lesson 29 – Metadata Reflection


Languages like C #, Java … all support class metadata, as well as API functions to read and write metadata for Class.

This reading and writing metadata is very useful when we want to perform logic based on checking information about the type inference of the class as well as the property in run-time.

Currently, native javascript for inference type support is very poor 😦 with only a few familiar ones:

  • typeof and instanceof (returns the type of the object to be checked)
  • Object.getOwnPropertyDescriptor(), Object.keys() (return a list of properties or keys of an object)

Similar to Decorator that was promised to officially appear in ES7, metadata reflection was also promised at least as prototype form since ES7, and officially implemented in javascript later.

However, from now on, we can use the reflect-metadata library to use these APIs. Currently, this API supports reading and writing metadata for objects through functions:

  • defineMetadata: add a metadata key to the target.
  • hasMetadata: checks the existence of a metadata based on the key.
  • getMetadata: get a key-based metadata.
  • deleteMetadata: delete 1 metadata.
  • getMetadataKeys:

Reflect.decorate
Applies a set of decorators to a property of a target object.
@param decorators An array of decorators.
@param target The target object.
@param propertyKey (Optional) The property key to decorate.
@param attributes (Optional) The property descriptor for the target key.
@remarks Decorators are applied in reverse order.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    Example = Reflect.decorate(decoratorsArray, Example);
       
    // property (on constructor)
    Reflect.decorate(decoratorsArray, Example, "staticProperty");
       
    // property (on prototype)
    Reflect.decorate(decoratorsArray, Example.prototype, "property");
       
    // method (on constructor)
    Object.defineProperty(Example, "staticMethod",
        Reflect.decorate(decoratorsArray, Example, "staticMethod",
            Object.getOwnPropertyDescriptor(Example, "staticMethod")));
       
    // method (on prototype)
    Object.defineProperty(Example.prototype, "method",
        Reflect.decorate(decoratorsArray, Example.prototype, "method",
            Object.getOwnPropertyDescriptor(Example.prototype, "method")));

Reflect.metadata
A default metadata decorator factory that can be used on a class, class member, or parameter.
@param metadataKey The key for the metadata entry.
@param metadataValue The value for the metadata entry.
@returns A decorator function.
@remarks
If `metadataKey` is already defined for the target and target key, the
metadataValue for that key will be overwritten.
@example

    // constructor
    @Reflect.metadata(key, value)
    class Example {
    }
       
    // property (on constructor, TypeScript only)
    class Example {
        @Reflect.metadata(key, value)
        static staticProperty;
    }
       
    // property (on prototype, TypeScript only)
    class Example {
        @Reflect.metadata(key, value)
        property;
    }
      
    // method (on constructor)
    class Example {
        @Reflect.metadata(key, value)
        static staticMethod() { }
    }
       
    // method (on prototype)
    class Example {
        @Reflect.metadata(key, value)
        method() { }
    }

Reflect.defineMetadata
Define a unique metadata entry on the target.
@param metadataKey A key used to store and retrieve metadata.
@param metadataValue A value that contains attached metadata.
@param target The target object on which to define metadata.
@param propertyKey (Optional) The property key for the target.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    Reflect.defineMetadata("custom:annotation", options, Example);
       
    // property (on constructor)
    Reflect.defineMetadata("custom:annotation", options, Example, "staticProperty");
       
    // property (on prototype)
    Reflect.defineMetadata("custom:annotation", options, Example.prototype, "property");
      
    // method (on constructor)
    Reflect.defineMetadata("custom:annotation", options, Example, "staticMethod");
      
    // method (on prototype)
    Reflect.defineMetadata("custom:annotation", options, Example.prototype, "method");
       
    // decorator factory as metadata-producing annotation.
    function MyAnnotation(options): Decorator {
        return (target, key?) => Reflect.defineMetadata("custom:annotation", options, target, key);
    }

Reflect.hasMetadata
Gets a value indicating whether the target object or its prototype chain has the provided metadata key defined.
@param metadataKey A key used to store and retrieve metadata.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns `true` if the metadata key was defined on the target object or its prototype chain; otherwise, `false`.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    result = Reflect.hasMetadata("custom:annotation", Example);
       
    // property (on constructor)
    result = Reflect.hasMetadata("custom:annotation", Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.hasMetadata("custom:annotation", Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.hasMetadata("custom:annotation", Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.hasMetadata("custom:annotation", Example.prototype, "method");

Reflect.hasOwnMetadata
Gets a value indicating whether the target object has the provided metadata key defined.
@param metadataKey A key used to store and retrieve metadata.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns `true` if the metadata key was defined on the target object; otherwise, `false`.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
      
    // constructor
    result = Reflect.hasOwnMetadata("custom:annotation", Example);
       
    // property (on constructor)
    result = Reflect.hasOwnMetadata("custom:annotation", Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.hasOwnMetadata("custom:annotation", Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.hasOwnMetadata("custom:annotation", Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.hasOwnMetadata("custom:annotation", Example.prototype, "method");

Reflect.getMetadata
Gets the metadata value for the provided metadata key on the target object or its prototype chain.
@param metadataKey A key used to store and retrieve metadata.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns The metadata value for the metadata key if found; otherwise, `undefined`.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    result = Reflect.getMetadata("custom:annotation", Example);
       
    // property (on constructor)
    result = Reflect.getMetadata("custom:annotation", Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.getMetadata("custom:annotation", Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.getMetadata("custom:annotation", Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.getMetadata("custom:annotation", Example.prototype, "method");

Reflect.getOwnMetadata
Gets the metadata value for the provided metadata key on the target object.
@param metadataKey A key used to store and retrieve metadata.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns The metadata value for the metadata key if found; otherwise, `undefined`.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
      
    // constructor
    result = Reflect.getOwnMetadata("custom:annotation", Example);
      
    // property (on constructor)
    result = Reflect.getOwnMetadata("custom:annotation", Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.getOwnMetadata("custom:annotation", Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.getOwnMetadata("custom:annotation", Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.getOwnMetadata("custom:annotation", Example.prototype, "method");

Reflect.getMetadataKeys
Gets the metadata keys defined on the target object or its prototype chain.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns An array of unique metadata keys.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    result = Reflect.getMetadataKeys(Example);
       
    // property (on constructor)
    result = Reflect.getMetadataKeys(Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.getMetadataKeys(Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.getMetadataKeys(Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.getMetadataKeys(Example.prototype, "method");

Reflect.getOwnMetadataKeys
Gets the unique metadata keys defined on the target object.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns An array of unique metadata keys.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    result = Reflect.getOwnMetadataKeys(Example);
       
    // property (on constructor)
    result = Reflect.getOwnMetadataKeys(Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.getOwnMetadataKeys(Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.getOwnMetadataKeys(Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.getOwnMetadataKeys(Example.prototype, "method");

Reflect.deleteMetadata
Deletes the metadata entry from the target object with the provided key.
@param metadataKey A key used to store and retrieve metadata.
@param target The target object on which the metadata is defined.
@param propertyKey (Optional) The property key for the target.
@returns `true` if the metadata entry was found and deleted; otherwise, false.
@example

    class Example {
        // property declarations are not part of ES6, though they are valid in TypeScript:
        // static staticProperty;
        // property;
       
        constructor(p) { }
        static staticMethod(p) { }
        method(p) { }
    }
       
    // constructor
    result = Reflect.deleteMetadata("custom:annotation", Example);
      
    // property (on constructor)
    result = Reflect.deleteMetadata("custom:annotation", Example, "staticProperty");
       
    // property (on prototype)
    result = Reflect.deleteMetadata("custom:annotation", Example.prototype, "property");
       
    // method (on constructor)
    result = Reflect.deleteMetadata("custom:annotation", Example, "staticMethod");
       
    // method (on prototype)
    result = Reflect.deleteMetadata("custom:annotation", Example.prototype, "method");

Combined with decorator, we can solve a number of problems that need to test object processing in run-time based on information about the Class or Type of that object.

Property Decorator and metadata reflection
Example 1:

import "reflect-metadata"
 
const formatMetadataKey = Symbol("format");

function format(formatString: string) {
    return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
    return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}

class Greeter {
    @format("Hello, %s")
    greeting: string;
  
    constructor(message: string) {
      this.greeting = message;
    }
    greet() {
      let formatString = getFormat(this, "greeting");
      return formatString.replace("%s", this.greeting);
    }
}
   
var g = new Greeter("Peter")
console.log(g.greet())

Build: tsc -t es2015 10.ts --experimentalDecorators -m commonjs

Result:

D:\TypeScript\Reflect>node 10.js
Hello, Peter

Parameter decorator, Method decorator and metadata reflection
Example 2: Validate for method params.

Usually, when writing a function, sometimes we have to check the value of the input params.

function doAJob(id:number, name:string) {
    if (name === undefined) { 
        throw Error 
    }   
    // ... 
}

This check is often repetitive and, therefore, we can separate the logic of commonly used validates into decorators. Here we use 2 decorators, 1 for the params and 1 for the method to be checked.

import "reflect-metadata"
 
function required(target: Object, propertyKey: string, paramIndex: number) {
    let existingRequiredParams: number[] = Reflect.getOwnMetadata('required', target, propertyKey) || []
    existingRequiredParams.push(paramIndex)
    Reflect.defineMetadata('required', existingRequiredParams, target, propertyKey)
}

With this parameter decorator, we first retrieve (or initialize) required metadata from the object containing the method. Then we push the index of params to be checked into this metadata and save it to Class.

function validate(target: any, propertyName: string, descriptor:PropertyDescriptor) {
    let method = descriptor.value
    descriptor.value = function () {
        let requiredParams: number[] = Reflect.getOwnMetadata('required', target, propertyName)
        if (requiredParams) {
            for (let paramIndex of requiredParams) {
                if (paramIndex >= arguments.length || arguments[paramIndex] === undefined) {
                    throw new Error("Missing required argument.")
                }
            }
        }
        return method && method.apply && method.apply(this, arguments)
    }
}

Next, in the method decorator, we retrieve the index of the required params from the metadata. For each index, we check the value of params at that index, if it’s equal to undefined => throw an exception.

At this point, using decorators for executing code becomes concise and helps focus more on the main job.

@validate
function doAJob(@required id:number, name:string) {
    if (name === undefined) { 
        throw Error 
    }   
    // ... 
}

Likewise, we can write many types of decorators to validate the common logic of a variable.

@format (check format based on regex ...)
@greater_than (validates are used for number type params ...)
...

Example 3: Complex types serialization from JSON
If anyone code Angular (as well as Typescript) have encountered the situation: when retrieving data from the server, we need to force this data type to Typed Object form.

We can use the following simple function to force a JSON object to a Typed Object form.

function convertObject<T>(outputType: {new(): T}, inputItem: Object): T | undefined {
    if (!!inputItem) { return Object.assign(new outputType(), inputItem); }
}

At this point, we can do the following:

class User {
    id: number
    name: string
}
  
const user = {
    id: 1, 
    name: 'Peter'
}
  
const typedUser = convertObject(User, user)

console.log(typedUser) //User { id: 1, name: 'Peter' }

The problem arises when the JSON input we get from the server is not just a simple object, but it can be in the form of a nested Object.

const classRoom = {
    id: 1,
    name: 'Class A1',
    students: [
       { id: 1, name: 'Peter' },
       { id: 2, name: 'Angel' },
       { id: 3, name: 'Mark' },
    ]
}

With the convert function above, Object.assign will simply copy the properties of the old object and assign it to the new object, so the result will be as follows:

class ClassRoom {
    id: number
    name: string
    students: Array<User>
}
  
const typedClassRoom = convertObject(ClassRoom, classRoom)

console.log(typedClassRoom)

/*
ClassRoom {
  id: 1,
  name: 'Class A1',
  students: [
    { id: 1, name: 'Peter' },
    { id: 2, name: 'Angel' },
    { id: 3, name: 'Mark' }
  ]
}
*/

Solution: Write a property decorator to mark the propety types that will be cast when converting objects.

class ClassRoom {
    id: number
    name: string
    @convertType(User) students: Array<User>
}

The implementation of the above decorator is quite simple:

function convertType<T>(type: T) {
    return function (target: Object, propertyName: string): void {
      Reflect.defineMetadata(propertyName, type, target)
    }
}

The above decorator will perform the task: When it encounters a marked property, it will store a pair (key, value) in metadata of that Class, with the value:

  • key: the name of that property (here is ‘students’)
  • value: type to be pressed (here User)

At this point, we rewrite the above convert function to:

function convertObject<T>(outputType: {new(): T}, input: Object): T | undefined {
    if (input) {
      const output = new outputType()
     
      const convertProperties = Reflect.getMetadataKeys(output)
      
      for (let key in input) {        
        if (convertProperties.includes(key)) {
          if (Array.isArray(input[key])) {
            output[key] = convertList(Reflect.getMetadata(key, output), input[key])
          } else {
            output[key] = convertObject(Reflect.getMetadata(key, output), input[key])
          }
        } else {
          output[key] = input[key];
        }
      }
      return output
    }
}
function convertList<T>(outputType: {new(): T}, input: Object[]):  (T | undefined)[] {
    let results = []
    for(let value of input){
        results.push(convertObject(outputType, value))
    }
    return results
}

The convert function will now do the following:

  • create output of the format to be converted.
  • Get a list of encounters (key, value) stored through the decorator above.
  • Check for each property of the input input:
    • If this property is not in the property list to convert, simply assign to a new object.
    • If yes, do recursive conversion for this property. (Note, Check if this propery is in array type, need to write a separate function to convert the type for Array to use 😛)
  • Returns object.

At this point, our output has been cast even against nested objects, as long as they are defined from within the class.

ClassRoom {
  id: 1,
  name: 'Class A1',
  students: [
    { id: 1, name: 'Peter' },
    { id: 2, name: 'Angel' },
    { id: 3, name: 'Mark' }
  ]
}

Leave a Reply