javascripttypescriptclassgenericsjsx

Typescript Classes use a static method in a base class typed for any children classes


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?


Solution

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

    Playground link to code