Decorator Metadata Update

Chris Hewell Garrett

2023-03

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'

Option 1: Shared Metadata Object (Current Proposal)

Champion's preferred approach. Decorator context receives an additional `metadata` property, which is a POJO.

  • All decorators applied to the class receive the same object
  • The object is assigned to the Symbol.metadata property after class definition
  • Metadata object inherits from parent class's metadata
  • Pros:
    • Very simple
    • Can do shared/public metadata and truly private metadata via WeakMap
    • Inheritance by default, but can opt-out by manually crawling prototype chain
  • Cons
    • Introduces a shared namespace
function meta(key, value) {
  return (_, context) => {
    context.metadata[key] = value;
  };
}

@meta('a' 'x')
class C {
  @meta('b', 'y')
  m() {}
}

C[Symbol.metadata].a; // 'x'
C[Symbol.metadata].b; // 'y'

class D extends C {
  @meta('b', 'z')
  m() {}
}

D[Symbol.metadata].a; // 'x'
D[Symbol.metadata].b; // 'z'

Option 2: Shared Key Object

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

  • `context.metadataKey` is a frozen object that is the same for an entire class
  • Object is exposed on class via `Symbol.metadata`
  • Object has a `parent` property which can be used to get the parent metadata
  • Pros:
    • Private by default
    • No shared namespace
  • Cons:
    • No way to share public metadata, e.g. type information, to be used by multiple parties
    • Inheritance is trickier to use
const METADATA = new WeakMap();

function meta(key, value) {
  return (_, context) => {
    let metadata = METADATA.get(context.metadataKey);

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

    metadata[key] = value;
  };
}

@meta('a' 'x')
class C {
  @meta('b', 'y')
  m() {}
}

METADATA.get(C[Symbol.metadata]).a; // 'x'
METADATA.get(C[Symbol.metadata]).b; // 'y'

class D extends C {
  @meta('b', 'z')
  m() {}
}

METADATA.get(D[Symbol.metadata].parent).a; // `x`
METADATA.get(D[Symbol.metadata]).b; // 'z'

Option 3: Same as current proposal, but limit to Symbol keys

  • Instead of direct access, add `getMetadata` and `setMetadata` functions to context object
  • `setMetadata` sets value on the metadata object, but ensures that the key is a Symbol
  • This helps to prevent collisions in the namespace by encouraging usage of Symbols by default
  • Pros:
    • Still a shared namespace, but less likelihood of accidental collisions since users must use Symbols, and can choose not to share a symbol
    • Inheritance works by default
  • Cons:
    • Cannot opt-out of inheritance during decoration
function meta(key, value) {
  return (_, context) => {
    context.setMetadata(key, value);
  };
}

@meta(Symbol.for('a'), 'x')
class C {
  @meta(Symbol.for('b'), 'y')
  m() {}
}

C[Symbol.metadata][Symbol.for('a')]; // 'x'
C[Symbol.metadata][Symbol.for('b')]; // 'y'

class D extends C {
  @meta(Symbol.for('b'), 'z')
  m() {}
}

D[Symbol.metadata][Symbol.for('a')]; // 'x'
D[Symbol.metadata][Symbol.for('b')]; // 'z'