Search code examples
javascripttypescriptdecoratoraccessorenumerable

How to create an "enumerable" decorator for accessor methods in TypeScript


I'm trying to make an @enumerable decorator that will expose properties defined via accessor methods.

A function to do this on instances of the class is fairly trivial:

// This works great when called in the class constructor like ```makeEnumerable(this, ['prop1', 'prop2'])```
const makeEnumerable = (what: any, props: string[]) => {
  for (const property of props) {
    const descriptor = Object.getOwnPropertyDescriptor(what.constructor.prototype, property);
    if (descriptor) {
      const modifiedDescriptor = Object.assign(descriptor, { enumerable: true });
      Object.defineProperty(what, property, modifiedDescriptor);
    }
  }
};

However, it does not seem possible to turn this into a decorator, because it doesn't have the instance.

// Does not work for Object.keys, Object.getOwnPropertyNames or Object.entries
function enumerable (value: boolean = true): any {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor): any {
    if (descriptor) {
      Object.assign(descriptor, { enumerable: value });
    }
  };
}

The property does still enumerate in for (const x in y) structures (strangely), but nowhere else - and worse, Object.entries throws an error.

Here is an example using the functions above:

class MyClass {
  #privateVal1: any;
  #privateVal2: any;

  constructor () {
    makeEnumerable(this, ['b']);
  }

  @enumerable(true)
  get a () {
    return this.#privateVal1;
  }

  set a (val: any) {
    this.#privateVal1 = val;
  }

  get b () {
    return this.#privateVal2;
  }

  set b (val: any) {
    this.#privateVal2 = val;
  }
}

const enumerableA = new MyClass();
enumerableA.a = 5;
enumerableA.b = 6;

const keys = [];
for (const key in enumerableA) {
  keys.push(key);
}

console.log({
  'forin': keys, // ['a', 'b']
  'keys': Object.keys(enumerableA), // ['b']
  'keys(proto)': Object.keys(Object.getPrototypeOf(enumerableA)), // ['a']
  'getOwnPropertyNames': Object.getOwnPropertyNames(enumerableA), // ['b']
  'getOwnPropertyNames(proto)': Object.getOwnPropertyNames(Object.getPrototypeOf(enumerableA)), // ['constructor', 'a', 'b']
});

console.log({
  'entries': Object.entries(enumerableA), // Error('Cannot read private member #privateVal1 from an object whose class did not declare it');
  'entries(proto)': Object.entries(Object.getPrototypeOf(enumerableA)), // Error('Cannot read private member #privateVal1 from an object whose class did not declare it');
});

Is there any way to use a decorator to make an accessor method an enumerable property?


Solution

  • Prototype vs Instance properties

    There's nothing strange in that, you'll have to understand Prototype vs Instance properties

    • makeEnumerable sets enumerable descriptors on the instance.
    • enumerable decorator modifies prototype-level descriptors.

    You are expecting Object.keys(enumerableA) to be ['a', 'b'], like 'forin': keys, but:

    • The for...in loop iterates over both own and inherited enumerable properties.
    • While, Object.keys returns only it's own enumerable properties.

    Enumerability and ownership of properties Enumerability and ownership of properties

    Check this MDN blog for more info. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties#querying_object_properties

    Explaination of the Outputs

    for (const key in enumerableA)

    • Output: ['a', 'b']
    • Why:
      • The for...in loop iterates over both own and inherited enumerable properties.
      • b and a are enumerable but on different level. b is made enumerable by makeEnumerable as instance property and a prototype property made enumerable by the @enumerable decorator.

    Object.keys(enumerableA)

    • Output: ['b']
    • Why:
      • Object.keys lists only the own enumerable properties.
      • b is made an own enumerable property by makeEnumerable function in constructor.
      • a is still on the prototype, so it is excluded.

    Object.keys(Object.getPrototypeOf(enumerableA))

    • Output: ['a']
    • Why:
      • It lists only enumerable properties on the prototype, @enumerable decorator modifies the prototype-level descriptor for a.
      • b is non-enumerable on prototype because makeEnumerable function made enumerable on instance only.

    Object.getOwnPropertyNames(enumerableA)

    • Output: ['b']
    • Why:
      • It lists all own properties (both enumerable and non-enumerable) but ignores prototype properties.
      • b is an own property on the instance.

    Object.getOwnPropertyNames(Object.getPrototypeOf(enumerableA))

    • Output: ['constructor', 'a', 'b']
    • Why:
      • This lists all own properties (both enumerable and non-enumerable) defined directly on the prototype.
      • constructor, and b are non-enumerable but exist on prototype.

    Why Object.entries throws an error

    Object.entries access all the enumerable own properties.

    • When querying Object.entries(enumerableA):
      • It access b as it is enumerable property on instance. While accessing b, the this context of get b() {...} is the instance MyClass { b: [Getter/Setter] }.
      • So it works fine, because the private properties in the class are accessible when this referes to the instance.
    • But when querying Object.entries(Object.getPrototypeOf(enumerableA))
      • It accesses a because a is an enumerable property on the prototype.
      • But it throws an error because the this context for the get a(){...} is the prototype object ({ a: [Getter/Setter] }), not an instance of MyClass

    You must understand how private properties are handled by typescript.

    • Typescript generate some functions to check this when a method is called. If this is not same as own class it throws an error.
    • Check compiled Javascript code for more details.

    Is there any way to use a decorator to make an accessor method an enumerable property?

    No, it is not possible to make instance properties enumerable directly using decorators in Typescript because property decorators in Typescript only have access to the class prototype for instance members, not the instance itself.

    • A property decorator in Typescript is executed before any instances of the class are created. It operates at the class definition level.
    • If you want to make instance properities enumerable use makeEnumerable function, as you used for b.



    I hope, I've addressed all your issues. If anything else you'd like to clarify, feel free to ask. Happy learning!