tc39 / proposal-decorator-metadata

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How to associate metadata with a specific target class method?

aedart opened this issue · comments

From the shown examples, I am having some trouble understanding why the keys of the metadata are strings or symbols, and not the actual class or its methods... Said differently, how do we associate metadata with a specific class method?

As an example, imagine a service of some kind, which two methods: execute and close. Both methods are decorated with a bit of metadata, stating some information about what kind of logger each method prefers. Note that in this example, we want to avoid a "forced" dependency injection (as seen in many other examples throughout the web) - we just want to store any kind of metadata and associate it with specific class methods.

class Service {

    @meta('logger', 'default-log')
    execute(logger) {
        // ...implementation not relevant here...
    }

    @meta('logger', 'null-log')
    close(logger) {
        // ...implementation not relevant here...
    }
}

// Later, when attempting to obtain "logger" key,...
let executeLogger = Service[Symbol.metadata].logger; //  'null-log-driver' ('default-log' value was overwritten)
let closeLogger = Service[Symbol.metadata].logger; //  'null-log-driver'

In the above example, we cannot reuse the same key twice, without overwriting the first occurrence in the "metadata" registry.
There appears to be no immediate relation between the target methods and assigned metadata (at least not from what the examples show).

Perhaps a more desirable solution could be to allow obtaining metadata by target function/object reference. E.g.

const service = new Service();

let executeLogger = [xxxxxxx].get(service.execute, 'logger'); // 'default-log'
let closeLogger = [xxxxxxx].get(service.close, 'logger'); // 'null-log'

The [xxxxxxx].get(target, key) represents some kind of method that allows specifying:

  • The target function or object you wish to read metadata from
  • Metadata key (string or symbol)

If metadata is found, it will be returned. Otherwise undefined is returned. Note: Shown method name ([xxxxxxx].get(target, key)) or format is NOT important - only its the purpose of being able to obtain metadata for a target function or object. The use-case is for situations when you do not necessarily know anything other than the method that is about to be invoked.

I'm not sure if above shown example is possible. One obvious issue is that when overwriting the methods in subclasses, then the original method's decorator(s) might not be invoked (decorators are seemingly only invoked when explicitly calling super.{method}() in overwritten method).
Even so, it would be really nice to be able to obtain metadata in such a way. Furthermore, subclasses and overwritten methods should ideally automatically inherit metadata (emphasis on "metadata" and not automatic appliance of parent method decorators!)

How could an object key be anything other than a string or a symbol? Metadata is an object, not a Map.

@ljharb My apologies if I have misunderstood something in regards to what the Metadata is - and yes, if its an object then string or symbol would be the only allowed as property identifiers. But if looking apart from that misunderstanding, my question still stands; how do we associate metadata with a specific class method?

I would assume you'd make your own data structure. For example, you could store a Map as the metadata value, and have the key of the Map be the class method itself.

Sounds interesting, would it be possible for you to state a quick example of such?... still having some issues understanding how to obtain arbitrary information, without knowing the actual class a given method originates from. E.g.

// Assuming that "run" is elsewhere in your application...
function run(method /* reference to a class method */)
{
    // How to obtain metadata about given "method" argument, without
    // knowing which class it originates from?
    // let meta = ???? for method;
}

@aedart the way you would write a decorator that does what you're describing here would be like so:

function logger(loggerType) {
  return (method, context) => {
    let loggerMeta = context.metadata.logger;
  
    if (!loggerMeta) {
      loggerMeta = context.metadata.logger = {};
    }
  
    loggerMeta[context.name] = loggerType;
  }
}

class Service {

  @logger('default-log')
  execute(logger) {
    // ...implementation not relevant here...
  }

  @logger('null-log')
  close(logger) {
    // ...implementation not relevant here...
  }
}

// Later, when attempting to obtain "logger" key,...
let executeLogger = Service[Symbol.metadata].logger.execute; //  'default-log'
let closeLogger = Service[Symbol.metadata].logger.close; //  'null-log-driver'

In the case of private methods/fields, you would use the access object to expose access to the slot and not use the name property (there isn't anything you can do with the name property, otherwise). You could also generalize this to a @meta decorator like you had before by having the metadata be an array and push into the array whenever a value is added for a key:

function meta(key, value) {
  return (method, context) => {
    let metaForKey = context.metadata[key];
  
    if (!metaForKey) {
      metaForKey = context.metadata[key] = [];
    }
  
    metaForKey.push({
      name: context.name,
      value,
    });
  }
}

class Service {

  @meta('logger', 'default-log')
  execute(logger) {
    // ...implementation not relevant here...
  }

  @meta('logger', 'null-log')
  close(logger) {
    // ...implementation not relevant here...
  }
}

// Later, when attempting to obtain "logger" key,...
let executeLogger = Service[Symbol.metadata].logger[0].value; //  'default-log'
let closeLogger = Service[Symbol.metadata].logger[1].value; //  'null-log-driver'

Though I generally think generic metadata decorators like this aren't particularly good design, they expose too many implementation details IMO, so I'd recommend making a more specific decorator for each use case.

Hi @pzuraq. Thanks for clarifying and the good examples.
I do still have one thing that I would like to have clarified. Is it possible to obtain metadata for a class method, when you only have the method reference. E.g.

// Somewhere in your application, where you only receive a method reference,
// without knowing the method's origin or class it belongs to.
function run(method) {
    // How do we read metadata for given method?
    let meta = ????[Symbol.metadata].get(method);

    // ... etc
}

I'm not trying to dismiss the ability of being able to defined metadata by means of decorators. It would be a great addition to JavaScript. But, from my understanding the metadata is limited to classes, class methods, their properties, etc. Furthermore, one requires knowledge about the given class that contains metadata, meaning that it does not seem possible to read it for a method, without knowing what class that method belongs to.

It is not really possible to do that, at least not in a way that would be composable. You would add metadata directly to the method itself, but another decorator could replace that method and not forward the metadata.

However, that’s also something I would consider an antipattern. Methods usually need the context of their class to make sense, that’s why they’re methods and not just functions. What use cases are you thinking of that would require you to read metadata specifically from a method, without the class context?

"[...] Methods usually need the context of their class to make sense, that’s why they’re methods and not just functions [...]", I absolutely agree!

One use case, that I had in mind, was concerned with dependency injection for class methods, but only when desired, by means of a service container. Some implementations that I have seen force inject dependencies using a decorator, regardless if you want it or not. I'm just interested in stating some kind of metadata about "desired" dependencies and allow developers to decide when and if they wish to resolve and inject them. This seems to be doable, given the examples you have shown (thanks for clarifying).

A different use case is... or rather was, to create a similar functionality as provided by Laravel's Service Container's (call) method. This method is commonly used for automatically resolving dependencies and invoking given callback with those dependencies as arguments. I know that this is only doable because of PHP's Reflections, which isn't anything like what JavaScript currently offers.

I guess that somehow I had hoped for being able to set metadata on both functions, classes, class methods, ...etc, and use the same mechanism to obtain desired meta information (whatever that might be), regardless of what the "target" is, or knowing its context. Given the possible extensions to decorators, e.g. Function decorators and annotations, does this proposal have any thoughts on how to deal with metadata for such possible future extensions?

Given the possible extensions to decorators, e.g. Function decorators and annotations, does this proposal have any thoughts on how to deal with metadata for such possible future extensions?

Yes, the idea is that function decorators would assign their metadata to Symbol.metadata directly on the function. This would be different behavior than with method decorators, but given that functions are meant to be used on their own, it also makes some sense that there would be a difference.

We considered assigning metadata for methods directly on the method function like this as well. This was ultimately rejected for a few reasons:

  1. It does not work as a model for all class elements. You could imagine a world where classes and class methods have completely separate metadata objects, and they each get assigned to Symbol.metadata. This even works for accessors, though it would be annoying. However, it really does not work for fields, which have nothing to assign Symbol.metadata on, and it would be very difficult to expose metadata this way for anything private.
  2. Given point 1, we know we need a metadata object to be shared for the whole class. We could additionally add a second metadata object for each method, but this would increase complexity, which was the main reason this proposal was held back in the first place.
  3. We also did not have a compelling reason to include this additional complexity, and it could always be added later in a backwards compatible way if one were ever to come up.

@pzuraq thanks for your replies. I think this answers all of my questions, at least for now.