Decorator Metadata

Chris Hewell Garrett

2022-09

Refresher: Why metadata?

  • Use cases:
    • Dependency Injection
    • ORMs (extremely useful to track database column information and size, foreign and primary key relationships, constraints, etc.)
    • Runtime Type Information (often used by DI and ORM, but also for runtime type checking, validation)
    • Serialization/Marshalling (i.e., controlling JSON.stringify output, or WASM or native platform interop)
    • Unit Testing (mark test methods on a class that serves as a test suite, specify related information such as execution contexts, issue numbers, etc.) MSTest and NUnit are good examples of this in C#/.NET
    • Routing (i.e., mark methods as GET or POST, or belonging to a specific url path, or what request body transforms to use)
    • Debugging (i.e., mark classes/methods as something the debugger should step over when debugging, or what alternative representation should be used in a watch/locals window)
    • Membranes (i.e., mark how certain methods, parameters, or return values should be interpreted when being passed in and out of a membrane-wrapped object)
  • Reflect.metadata is the single most used decorator library (> 5 mil monthly downloads), suggests users find the pattern valuable

How it used to work

Legacy decorators used to receive the class definition as the first argument. Decorators could use the class as a key in a WeakMap to expose metadata.

  • Every decorator had to define its own way of adding metadata.
  • No support for adding metadata to private elements.
  • No longer possible in current proposal because decorators do not receive the class.
const MY_META = new WeakMap();

class Metadata {
  public = {};
  own;
}

function addMeta(value) {
  return function(Class, name, desc) {
    let metadata = MY_META.get(Class);

    if (!metadata) {
      metadata = new Metadata();
      MY_META.set(Class, metadata);
    }

    if (name) {
      metadata.public[name] = value;
    } else {
      metadata.own = value;
    }
  } 
}

@addMeta('class')
class C {
  @addMeta('pub') x;
}

const metadata = MY_META.get(C);

metadata.own; 
// 'class'
metadata.public.x; 
// 'pub'

Current Proposal

Decorators can add metadata which is then readable by external code.

  • Metadata is defined via `setMetadata`
  • Metadata is keyed on a Symbol
  • Metadata is split between public and private
  • Metadata is inherited
  • Accessible on the class via `Symbol.metadata`
const MY_META = Symbol();

function addMeta(value) {
  return function(m, context) {
    context.setMetadata(MY_META, value);
  } 
}

@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

C.prototype[Symbol.metadata][MY_META].own; 
// 'class'
C.prototype[Symbol.metadata][MY_META].public.x; 
// 'pub'
C.prototype[Symbol.metadata][MY_META].private[0]; 
// 'priv'

Alternative 1: Simpler metadata object

Same general idea as current proposal, but all metadata gets added to a single array.

  • Users have to figure out inheritance and how to distinguish their metadata from others.
  • Metadata added in decoration order.
const MY_META = Symbol();

function addMeta(value) {
  return function(m, context) {
    context.addMetadata(value);
  } 
}

@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

C.prototype[Symbol.metadata][0]; 
// 'pub'
C.prototype[Symbol.metadata][1]; 
// 'priv'
C.prototype[Symbol.metadata][2]; 
// 'class'

Alternative 2: Shared Key Object

Decorators receive an object on `context` that can be used as key for metadata 

  • `context.metadata` is a frozen object that is the same for an entire class
  • Details of how metadata is stored/shared are left up to the user
  • Object is exposed on class via `Symbol.metadata`
const METADATA = new WeakMap();

class Metadata {
  private = [];
  public = {};
  own;
}

function addMeta(value) {
  return function(m, context) {
    let metadata = METADATA.get(context.metadata);

    if (!metadata) {
      metadata = new Metadata();
      METADATA.set(context.metadata, metadata);
    }

    if (context.private) {
      metadata.private.push(value);
    } else if (context.kind === 'class') {
      metadata.own = value;
    } else {
      metadata.public[context.name] = value;
    }
  } 
}

@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

const metadata = METADATA.get(C[Symbol.metadata]);

metadata.own; 
// 'class'
metadata.public.x; 
// 'pub'
metadata.private[0]; 
// 'priv'

Alternative 2a: Shared Key Object w/o Symbol.metadata

Decorators receive an object on `context` that can be used as key for metadata

  • `context.metadata` is a frozen object that is the same for an entire class
  • Details of how metadata is stored/shared are left up to the user
  • Object is NOT exposed on class. Users must expose it manually via a class decorator
const CONTEXT_TO_METADATA = new WeakMap();
const CLASS_TO_METADATA = new WeakMap();

class Metadata {
  // ...
}

function addMeta(value) {
  // Similar to previous example...
}

function exposeMeta(C, context) {
  const metadata = CONTEXT_TO_METADATA.get(context.metadata);
  CLASS_TO_METADATA.set(C, metadata);
}

@exposeMeta
@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

const metadata = CLASS_TO_METADATA.get(C);

Alternative 3: addInitializer placement

  • Static initializers run after class definition
  • Static initializers receive the class definition itself
  • Only static elements can add static initializers currently
  • In this version, we add `placement` as an option to addInitializer for all decorators/elements
const MY_META = new WeakMap();

class Metadata {
  // ...
}

function addMeta(value) {
  return function(m, context) {
    context.addInitializer(
      function() {
        let metadata = MY_META.get(this);

        if (!metadata) {
          metadata = new Metadata();
          MY_META.set(this, metadata);
        }
    
        // ...
      },
      'static'
    )


  } 
}

@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

const metadata = MY_META.get(C);

Alternative 4: Do nothing

In the current proposal, users can expose metadata by using class and field decorators that share state.

 

This is fairly unergonomic and also can be error prone (e.g. if you accidentally reuse a stateful decorator for a different class).

const CLASS_TO_METADATA = new WeakMap();

class Metadata {
  // ...
}

function makeMetaDecorators() {
  const metadata = new Metadata();

  function addMeta(value) {
    // Similar to previous example...
  }
  
  function exposeMeta(C, context) {
    CLASS_TO_METADATA.set(C, metadata);
  }  

  return { addMeta, exposeMeta };
}

const { addMeta, exposeMeta } = makeMetaDecorators();

@exposeMeta
@addMeta('class')
class C {
  @addMeta('pub') x;
  @addMeta('priv') #x;
}

const metadata = CLASS_TO_METADATA.get(C);