I am trying to write a wrapper for web components which allows you to use them in JSX by referencing their key; implicitly this will also register the custom element if not done so already so that you don't forget to.
Is it possible to have some abstract base class which defines a static method, and have children classes which extend this class, and have correct typing when accessing the method.
For example:
abstract class Base {
static _key: string
static get key() {
console.log(this._key)
return this._key
}
}
class Child extends Base {
static _key = "child" as const
}
// Want key to be of type "child", not string
const key = Child.key
If possible, I would prefer to do something like this (because this would allow me to type the props without having to modify IntrinsicElements
everywhere), but I'm noticing a similar problem
abstract class Base {
abstract props: Record<string, unknown>
static Render<T extends typeof Base>(this: T, props: InstanceType<T>["props"]) {
return "jsx-representation-of-element"
}
}
class Child extends Base {
props = { hello: "world" }
}
// Some other file, test.tsx
// Expecting that the type is inferred to need Child's props, not Base's
// It doesn't care that hello is missing because the typeof this is
// is inferred to be Base
Child.Render({ hello: "yb" }) // okay 👍
{ <Child.Render hello="yb" /> } // okay 👍
Child.Render({ hello: 1 }) // error 👍
{ <Child.Render hello={1} /> } // NO ERROR?! 😢
I have been able to get around this by adding static key: typeof this._key
to Child
but this would require me adding it everywhere, so hoping I can find a solution which doesn't require this.
For the first part, I looked into using this
as a parameter but this can't be done on getters. I realise I could solve this by making it a normal method, and then calling it once outside the component function, referencing that variable inside the jsx tag, but that seems quite verbose.
I also tried making Base
take a generic parameter and returning this in Base.key
, ignoring the error about static members and generic types, but this just made the type T
(effectively any
)
For the second part, I was able to use the this
parameter, but as mentioned, the Child.Render
function thinks the this
type is Base
because it's not been called yet.
Obviously, once I call Child.Render()
, the types are fine, but this won't work if using inside a jsx tag.
Is there some tsconfig option I can use to make it default to Child
's this
?
As you've noted, TypeScript doesn't support the polymorphic this
type for static
class members. There is a longstanding open issue at microsoft/TypeScript#5863 asking for such functionality, but it's not part of the language right now. It looks like you tried the "standard" workaround of using a this
parameter on a generic static method.
That works when you call the method directly, but as you've seen this does not work with JSX elements. This is a known bug in TypeScript where this
parameters in functions are not properly instantiated with a type argument inside JSX elements. This was reported at microsoft/TypeScript#55431 and for now remains unfixed.
So you'll have to give up or work around it. Right now the only workaround I can think of is to change the scope of the generic type parameter so that the method knows about it already, and can be referred to directly without needing the this
parameter. That means you want Base
to be generic... but that doesn't quite work because static members can't access a type parameter. Instead you can make Base
a class factory function that returns a class constructor. Like this:
function Base<T extends Record<string, unknown>>(initProps: T) {
return class {
props: T = initProps
static Render(props: T) {
return "jsx-representation-of-element"
}
}
}
So here Base
is not a class by itself. Instead you must call Base
and pass it the initial props
. Then it retuns a class for which the type T
of props
is known to the class:
class Child extends Base({ hello: "world" }) { }
So the class returned by Base({hello: "world"})
is not generic. The type {hello: string}
is just part of the type of that class, and thus Base({hello: "world"}).Render()
only accepts an argument of type {hello: string}
. And therefore so does Child
since it's a (currently blank) extension of Base({hello: "world"})
.
That yields the behavior you're looking for:
Child.Render({ hello: "yb" }) // okay 👍
{ <Child.Render hello="yb" /> } // okay 👍
Child.Render({ hello: 1 }) // error 👍
{ <Child.Render hello={1} /> } // error 👍
That answers the question as asked. But there's a caveat: class factory functions are not the same as superclasses.
Note that class factories don't really use inheritance at runtime, so you can't easily use (for example) the instanceof
operator to check if something is descended from Base
or not. Certainly child instanceof Base
is going to be false because Base
is not a class, and child instanceof Base({hello: "yb"})
is also going to be false. But since each call to Base()
produces a new class, there's no common parent class for two different Base()
outputs. You might be able to play around and get that to work, but it's not simple.