BeGo
BeGo

Reputation: 175

is it possible to create dynamic properties to a class?

I am wrapping an enum in a class so I can map its values in TypeScript

enum Color {
    RED = 0,
    GREEN = 1,
    BLUE = 2
}

const MapColor = new EnumMap(Color); // Wrapper

console.log(MapColor.map()) // [{ "key": "RED", "value": 0}, { "key": "GREEN","value": 1}, { "key": "BLUE",  "value": 2}] 
console.log(MapColor.entries()) // [["RED", 0], ["GREEN", 1], ["BLUE", 2]] 
console.log(MapColor.keys()) // ["RED", "GREEN", "BLUE"] 
console.log(MapColor.values()) //  [0, 1, 2] 

Wrapper Class

class EnumMap {
  private _map = new Map<string, string | number>();
  public enum: any;

  constructor(enumeration: any) {
    this.init(enumeration);
    return this;
  }

  private init(enumeration: any) {
    this._map = new Map<string, string | number>();

    for (let key in enumeration) {
      if (!isNaN(Number(key))) continue;
      const val = enumeration[key] as string | number;
      if (val !== undefined && val !== null) this._map.set(key, val);
    }

    this.enum = enumeration;
  }

  map = (): Object => Array.from(this._map.entries()).map((m) => ({ key: m[0], value: m[1] }));
  entries = (): any[] => Array.from(this._map.entries());
  keys = (): any[] => Array.from(this._map.keys());
  values = (): any[] => Array.from(this._map.values());
}

I would like to access an enum value directly, I want to do this

MapColor.RED  // 0
MapColor.RED.toString()  // 'RED'

Is it possible to create dynamic properties to a class? If possible, how would it be done?

Upvotes: 0

Views: 69

Answers (1)

jcalz
jcalz

Reputation: 329013

ASIDE:

I am ignoring the request that MapColor.RED be 0 but MapColor.RED.toString() evaluate to "RED". If you want MapColor.RED === Color.RED to be true, so that MapColor.RED is the number (primitive data type) whose value is 0, it is completely impossible for you to get "RED" as its string value, since (0).toString() is just "0". If you want MapColor.RED to be a Number (wrapper object), you could do it as Object.assign(Number(0), {toString(){return "RED"}), but what comes out of that is awful. It kind of acts like 0 (MapColor.RED + 5 === 5), except when it doesn't, since MapColor.RED === 0 is false, and someObject[MapColor.RED] will access the RED property of someObject, not the 0 property. So you either cannot do this, or you can but you shouldn't. So I'm pretending it doesn't exist. 🙈


ANSWER:

You can implement such a class by copying the properties of enumeration directly to this inside the constructor (or the init() method, I guess). And you can describe the type of such a class constuctor using generics via intersection. What you cannot do is have the compiler verify that the implementation obeys the description. You will therefore need to use type assertions in several places to tell the compiler that your class instances and class constructor conform to the behavior in question:

class _EnumMap<T extends Record<keyof T, string | number>> {
    private _map = new Map<keyof T, T[keyof T]>();
    public enum!: T;

    constructor(enumeration: T) {
        this.init(enumeration);
        return this;
    }

    private init(enumeration: T) {
        this._map = new Map<keyof T, T[keyof T]>();

        for (let key in enumeration) {
            if (!isNaN(Number(key))) continue;
            const val = enumeration[key];
            if (val !== undefined && val !== null) {
                this._map.set(key, val);
                (this as any)[key] = val; // set value here
            }
        }

        this.enum = enumeration;
    }

    map = () => Array.from(this._map.entries())
        .map((m) => ({ key: m[0], value: m[1] })) as
        Array<{ [K in keyof T]: { key: K, value: T[K] } }[keyof T]>;
    entries = () => Array.from(this._map.entries()) as
        Array<{ [K in keyof T]: [K, T[K]] }[keyof T]>;
    keys = () => Array.from(this._map.keys()) as Array<keyof T>;
    values = () => Array.from(this._map.values()) as Array<T[keyof T]>;
}

I have renamed EnumMap out of the way to _EnumMap and I restore the EnumMap name at the end:

type EnumMap<T extends Record<keyof T, string | number>> = _EnumMap<T> & T;
const EnumMap = _EnumMap as 
  new <T extends Record<keyof T, string | number>>(enumeration: T) => EnumMap<T>;

The renamed EnumMap is generic in the type T of the enumeration object passed to it. Inside init() I write this[key] = val, but the compiler does not know that this should have keys corresponding to key, so I assert this as any to loosen up the compiler warnings. I changed the output types of all your existing methods too, to correspond more correctly to what you're getting out of them... but you didn't ask for that, so keep any and Object (or probably object) if you must.

By writing type EnumMap<T> = _EnumMap<T> & T, I'm saying that an EnumMap<T> acts both as your original class, and as a T. So if T looks sort of like {RED: 0, GREEN: 1, BLUE: 2}, then EnumMap<T> will also have those properties. And by writing const EnumMap = _EnumMap as new<T>(enumeration: T) => EnumMap<T>, I'm saying that the original class constructor has been copied to a value named EnumMap, and can be treated as a class constructor for instances of the new EnumMap<T>.


Let's test it:

enum Color {
    RED = 0,
    GREEN = 1,
    BLUE = 2
}

const MapColor = new EnumMap(Color); // Wrapper

console.log(MapColor.map()) // [{ "key": "RED", "value": 0}, 
  // { "key": "GREEN","value": 1}, { "key": "BLUE",  "value": 2}] 
console.log(MapColor.entries()) // [["RED", 0], ["GREEN", 1], ["BLUE", 2]] 
console.log(MapColor.keys()) // ["RED", "GREEN", "BLUE"] 
console.log(MapColor.values()) //  [0, 1, 2] 
console.log(MapColor.RED) // 0
console.log(MapColor.GREEN) // 1
console.log(MapColor.BLUE) // 2

Looks good to me.


Playground link to code

Upvotes: 1

Related Questions