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.
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.