1

I'm writing some frontend code in TypeScript, creating some Custom Elements by hand.

Custom Elements let me define "observed attributes" as an array of strings. I want all of these observed attributes to also be properties on the class, so that I can for example myElement.attr1 = 'value' instead of myElement.setAttribute('attr1', 'value'). Right now, I'm achieving that with this metaprogrammy code:

class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ['attr1', 'attr2', 'attr3'];
  }
}

for (const attr of MyElement.observedAttributes) {
  Object.defineProperty(MyElement.prototype, attr, {
    get: function() { return this.getAttribute(attr) },
    set: function(value) {
      if (value === null) this.removeAttribute(attr);
      else this.setAttribute(attr, value);
    }
  });
}

This works great, but TypeScript does not know about the properties I'm adding, even though they're all completely static. Is there some trick I can use to tell typescript that I'm adding properties for each string in observedAttributes?

I've tried something like this:

const myObservedAttributes = {
  'attr1': true,
  'attr2': true,
  'attr3': true,
};

type DynamicAttrs = {
  [Key in keyof typeof myObservedAttributes]: string | null;
}

class MyElement extends HTMLElement implements DynamicAttrs {
  static get observedAttributes() {
    return Object.keys(myObservedAttributes);
  }
}

// ... Object.defineProperty loop from above

but of course TypeScript complains that I'm not implementing any of the properties in DynamicAttrs because it doesn't understand Object.defineProperty.

Resonious
  • 98
  • 5
  • Does the following help: [https://stackoverflow.com/questions/12710905/how-do-i-dynamically-assign-properties-to-an-object-in-typescript?rq=1](https://stackoverflow.com/questions/12710905/how-do-i-dynamically-assign-properties-to-an-object-in-typescript?rq=1) – bdcoder Jan 12 '23 at 02:49
  • I think that solution is equivalent to my `DynamicAttrs` type (which didn't work). It requires me to manually define each property, *and* keep a list of each property in the type. I want to only have one list of properties in my source code. – Resonious Jan 12 '23 at 02:55
  • 1
    Hmm -- found this as well: https://fettblog.eu/typescript-assertion-signatures/ – bdcoder Jan 12 '23 at 03:10
  • Does [this approach](https://tsplay.dev/NrDVDW) using [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) meet your needs? If so I will write up an answer explaining; if not, what am I missing? – jcalz Jan 12 '23 at 04:17
  • @jcalz Hey yeah that does seem to work: https://cutt.ly/K2FGdGp - thanks for the reference! – Resonious Jan 12 '23 at 04:31
  • 1
    I will write up an answer when I get a chance, it might be tomorrow evening – jcalz Jan 12 '23 at 04:40

2 Answers2

2

First, let's modify your observedAttributes() getter so that the return type will be a strongly typed array of string literal types and not just string[]:

class MyElement extends HTMLElement {
    static get observedAttributes() {
        return ['attr1', 'attr2', 'attr3'] as const;
    }
}

The const assertion tells the compiler to infer a more specific type for the value. Now if you inspect observedAttributes with IntelliSense, you'll see that the type is

// (getter) MyElement.observedAttributes: readonly ["attr1", "attr2", "attr3"]

Now, if you want to tell TypeScript that you have augmented a class instance type, you can use declaration merging to "re-open" the interface corresponding to the class instance. For your example it could look like this:

interface MyElement extends Record<
    typeof MyElement.observedAttributes[number], string | null
> { }

As mentioned above, typeof MyElement.observedAttributes is readonly ["attr1", "attr2", "attr3"]. When we index into that type with number (typeof MyElement.observedAttributes[number]) we get the union of the types of the elements: "attr1" | "attr2" | "attr3". By merging Record<typeof MyElement.observedAttributes[number], string | null, using the Record<K, V> utility type into the MyElement interface, we're saying that a MyElement instance should have attr1, attr2, and attr3 properties of type string | null.


Let's test it out, using custom elements and merging the element type into the associated TypeScript interface so that the compiler knows about it:

if (!customElements.get("my-element"))
    customElements.define("my-element", MyElement);

interface HTMLElementTagNameMap {
    "my-element": MyElement;
}

const myEl = document.createElement("my-element");
// const myEl: MyElement

myEl.attr1 = "abc";
console.log(myEl.attr1.toUpperCase()) // "ABC"
myEl.attr2 = "def";
console.log(myEl.attr2.toUpperCase()) // "DEF"
myEl.attr2 = null;
console.log(myEl.attr2) // null
myEl.attr3 = "ghi";
console.log(myEl.attr3.toUpperCase()) // "GHI"

Looks good. The compiler understands that myEl is a MyElement and therefore that it has attr1, attr2, and attr3 properties, as desired.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
1

For such a situation, I'll use a factory :

    function getObservedElementFactory<ObservedProps extends string> (...observed: ObservedProps[]) {
        type IMyElement = {
            [P in ObservedProps] : ReturnType<HTMLElement["getAttribute"]>;
        };
    
        class MyElement extends HTMLElement { }
    
        for (const attr of observed) {
            Object.defineProperty(MyElement.prototype, attr, {
                get: function () { return this.getAttribute(attr); },
                set: function (value) {
                    if (value === null) this.removeAttribute(attr);
                    else this.setAttribute(attr, value);
                },
            });
        }
    
        return () => new MyElement() as MyElement & IMyElement;
    }
    
    export const createObservedOne = getObservedElementFactory("attr1", "attr2", "attr3");
    
    const a = createObservedOne();
    a.attr1 = "toto"; // ok
    
    export const createObservedSecond = getObservedElementFactory("attr4", "attr5", "attr6");
    
    const b = createObservedSecond();
    b.attr4 = "toto"; // ok

There are some drawbacks, each generated factory has a different subclass of HTMLElement and other stuff. But this might help depending on your needs.