Typescript decorators + Reflect metadata
Asked Answered
H

1

6

I'm using a property decorator Field which pushes its' key to a fields Reflect metadata property:

export function Field(): PropertyDecorator {
    return (target, key) => {
        const fields = Reflect.getMetadata('fields', target) || [];
        if (!fields.includes(key)) {
            fields.push(key)
        }
        Reflect.defineMetadata('fields', fields, target)
    }
}

I then have an abstract base-class Form that accesses the metadata in a getter accessory:

abstract class Form {
    get fields() {
        return Reflect.getMetadata('fields', this) || [];
    }
}

I have so far been able to use it successfully to distinguish form fields from other class properties. Consider these classes:

abstract class UserForm extends Form {
    @Field()
    public firstName: string

    @Field()
    public lastName: string

    get fullName() {
        return this.firstName + ' ' + this.lastName;
    }
}

class AdminForm extends UserForm {
    @Field()
    roles: string[]
}

const form = new AdminForm()
console.log(form.fields)
// ['roles', 'firstName', 'lastName']

The problem occurs when I define a sister class to AdminForm - MemberForm. When multiple subclasses exists to Form it seems the fields getter returns all fields:

class MemberForm extends UserForm {
    @Field()
    memberSince: Date;
}

const form = new AdminForm()
console.log(form.fields)
// ['roles', 'firstName', 'lastName', 'memberSince'] <--!!!

This makes no sense to me. Why does the memberSince field appear on an instance of AdminForm? How can I define different fields on different subclasses?

Hilleary answered 12/3, 2019 at 8:36 Comment(0)
E
10

The problem is getMetadata goes down the prototype chain and will always return what is defined on the base type (since that gets assigned first). You need to use getOwnMetadata to get the array field of the current class only when you add a new field and when getting the fields you need to walk up the property chain to get all base class fields.

This should work:

import 'reflect-metadata'
export function Field(): PropertyDecorator {
  return (target, key) => {
      const fields = Reflect.getOwnMetadata('fields', target) || [];
      if (!fields.includes(key)) {
          fields.push(key)
      }
      Reflect.defineMetadata('fields', fields, target)
  }
}

abstract class Form {
  get fields() {
      let fields = []
      let target = Object.getPrototypeOf(this);
      while(target != Object.prototype) {
        let childFields = Reflect.getOwnMetadata('fields', target) || [];
        fields.push(...childFields);
        target = Object.getPrototypeOf(target);
      }
      return fields;
  }
}

abstract class UserForm extends Form {
  @Field()
  public firstName!: string

  @Field()
  public lastName!: string

  get fullName() {
      return this.firstName + ' ' + this.lastName;
  }
}

class AdminForm extends UserForm {
  @Field()
  roles!: string[]
}

const form1 = new AdminForm()
console.log(form1.fields) // ['roles', 'firstName', 'lastName']

class MemberForm extends UserForm {
  @Field()
  memberSince!: Date;
}

const form2 = new MemberForm()
console.log(form2.fields) // ["memberSince", "firstName", "lastName"]
Enthronement answered 12/3, 2019 at 8:48 Comment(2)
Interesting! If I change getMetadata to getOwnMetadata on the property decorator, then I get ['roles'] on fields. So what do you mean by walking up the property chain?Hilleary
Wow! That's amazing! Thanks a lot!Hilleary

© 2022 - 2024 — McMap. All rights reserved.