javascripttypescripttypesweb-componentcustom-element

Is it possible to create a type definition for dynamically generated class methods in typescript?


Is there a way to create a type definition for class method names that are dynamically generated and follow a naming format like {name}Ref? Basically, I'm wondering if something like this is possible:

export declare class BaseElement extends HTMLElement {
  // ... other type definitions ...
  [`${Lowercase<string>}Ref`](): HTMLElement | null {} // <-- is something like this possible?
}

Context:

I'm building a custom element base class that will find child elements with the attribute :ref="name" and dynamically create a new method on the base class that will return a reference to the node the attribute was defined on and follows the naming format: {name}Ref.

The code is completely functional at this point, but I'm having a problem with correctly defining types for it.

Here's an abridged version of the base class:

class BaseElement extends HTMLElement {
  constructor() {
    super();
    let node;
    const iterator = document.createNodeIterator(this, NodeFilter.SHOW_ELEMENT);
    while ((node = iterator.nextNode())) {
      if (!node) return;
      if (node instanceof HTMLElement) {
        for (const attr of node.attributes) {
          if (attr.name === ':ref') {
            this.#generateRefMethod(attr);
          }
        }
      }
    }
  }
  #generateRefMethod(attr) {
    const elem = attr.ownerElement;
    if (elem && elem instanceof HTMLElement) {
      const refName = `${attr.value}Ref`;
      // Attach getter method to BaseElement
      this[refName] = () => elem;
      // Remove `:ref` attribute from elem since it's 
      // a non-standard HTML attribute
      elem.removeAttributeNode(attr);
    }
  }
}

The problem:

When I consume/use this in a typescript file I get an error saying the method does not exist on the child class.

import {BaseElement} from './BaseElement.js';
class Child extends BaseElement {   
  constructor() {
    super();
    const ref = this.someRef?.(); // Error: Property 'someRef' does not exist on type 'Child'.
    if (ref) {
      ref.textContent = "some text";
    }
  }
}

Current solution:

I figured out I can use the declare keyword to resolve this problem, but this can get a bit verbose/annoying to declare every single ref on every single class that extends BaseElement.

class Child extends BaseElement {
  declare someRef: () => HTMLElement | null;    
  constructor() {
    super();
    const ref = this.someRef?.(); // No more error!
    if (ref) {
      ref.textContent = "some text";
    }
  }
}

It would be great if the BaseElement class could be responsible for defining the method types instead of the child class, thus we circle back to the question above, is something like this is possible?

export declare class BaseElement extends HTMLElement {
  // ... other type definitions ...
  [`${Lowercase<string>}Ref`](): HTMLElement | null {} // <-- is something like this possible?
}

Extra notes:

Here's a slight variant of the minimal reproduction in Stackblitz showing a counter button example.

The BaseElement class is defined in a javascript file and then I've handwritten my types in a .d.ts file, so if answers could be catered to that reality that would be appreciated.

I also found this StackOverflow question that seems somewhat related and discusses a solution for defining method name types that follow a pattern of {dynamicString}Postfix, but I really struggled to translate the solution of that problem to what I'm working on.


Solution

  • You can indeed give a class or an interface a template string pattern index signature, like this:

    declare class BaseElement extends HTMLElement {
        [x: `${Lowercase<string>}Ref`]: (() => HTMLElement | null) | undefined
    }
    

    which means that any property of BaseElement whose key is a lowercase string followed by "Ref" is either a no-arg function returning HTMLElement | null or it is undefined. Only strings matching `${Lowercase<string>}Ref` are given that type:

    this.ABCRef // error
    this.abcRef // okay
    this.abcDef // error
    

    Only this.abcRef is recognized because it's of the right form, whereas this.ABCRef fails because ABC isn't lowercase, and abcDef fails because it doesn't end in Ref.

    And note that I added that undefined possibility in the value type to remind you to check that some random method exists before calling it:

    class Something extends BaseElement {
        foo() {
            const r = this.abcdeRef?.();
            // optional chaining ->^^
            // const r: HTMLElement | null | undefined
        }
    }
    

    That uses the optional chaining operator (?.) to call this.abcdeRef if and only if it is present, and the result r is either HTMLElement | null or undefined.

    Checking for null and undefined is recommended, but if you don't want that to be forced to do such checks, you can remove undefined from the index signature value (and make sure --noUncheckedIndexedAccess is disabled). And if you do that, don't be surprised if you hit runtime errors.

    Playground link to code