Search code examples
typescriptconstructorthisabstract-classprototypal-inheritance

Giving a type to polymorphic static methods used as public constructors


I have a number of classes all implementing the same interface. In order to create instances of these classes one should not use the constructor but rather a number of static methods which take a different input, transform it, validate it and eventually return a new instance of the class.

This works perfectly in JavaScript thanks to its prototypal inheritance:

class Base {
  static newX(...args) {
    // here I need to validate and transform `args`
    return new this(...args);
  }
  static newY(...args) {
    // here I need to transform `args`
    return this.newX(...args);
  }
  toString() {
    return `${this.constructor.name}:${JSON.stringify(this)}`
  }
}

class A extends Base {
  constructor() {
    super();
  }
}
class B extends Base {
  constructor(n){
    super();
    this.n = n;
  }
  static newX(n) {
    // here I need to do something different from Base.newX
    return new this(n);
  }
}
class C extends Base {
  constructor(str, n){
    super();
    this.str = str;
    this.n = n;
  }
  static newY(str, n) {
    // here I need to do something different from Base.newY
    return this.newX(str, n);
  }
}

// everything should work
console.log(`${A.newX()}`);
console.log(`${A.newY()}`);
console.log(`${B.newX(1)}`);
console.log(`${B.newY(1)}`);
console.log(`${C.newX("hi", 0)}`);
console.log(`${C.newY("hi", 0)}`);

How can I give a type to this code using TypeScript?

I tried in a few different ways, but each of them has problems. Here is a TypeScript playground link to see my attempts and play around with this piece of code: https://tsplay.dev/w2OA4N


Solution

  • The main problem here is described in microsoft/TypeScript#4628. Your static methods in the subclass are not considered proper overrides for the ones in Base. TypeScript has the same type restrictions on the static side of class as it does on the instance side: if you override something, you need to be sure that it can be substituted for the base, a.k.a. it needs to obey the Liskov Substitution Principle. So if you can write Base.newX("anything I'd like"), then A.newX also needs to accept "anything I'd like", it can't suddenly decide it only accepts numbers. This is the same problem you'd run into if you tried it with non-static methods:

    declare class Super {
      method<A extends unknown[], T>(...a: A): T;
    }
    class Sub extends Super {
      method(n: number) { return super.method(n) }; // error!
    }
    

    Of course there's one big glaring exception to the Liskov Substitution Principle for the static side of classes: the constructor itself. You are always allowed have the subclass constructor take completely different arguments from the superclass constructor. This exception was carved out in TypeScript to allow idiomatic JavaScript class hierarchies to mostly work. But it does mean that you can't just substitute a subclass constructor for a superclass constructor and expect it to work.

    So why couldn't they make the same exception for static things as well? That's what microsoft/TypeScript#4628 is about. The current situation is as described in this comment:

    • Static inheritance is part of the ES6 spec, but...

    • Some people use the static side of classes (i.e. constructor functions with static properties) polymorphically, and some don't

    • The first group wants this check as much as they want general substitutability checks for the instance side of classes

    • The second group doesn't care at all if their static members align or not

    • The first group should have their substitutability failures detected at the use sites, which is worse than at declaration sites, but still (mostly) works

    • The second group is just out of luck in our current design

    Conclusion: Try removing the check for assignability of the static side of classes and see what kind of errors go away (mostly in the baselines, since we mostly have clean code in our real-world-code baselines). Proceed depending on those results.

    The Committed tag here is tentative.

    You're mostly "the second group" here. Unfortunately... nothing really happened to this issue after that. There are some related suggestions but they are all still open without any indication something will change.


    So if you want to use static inheritance for this purpose, you'll need to work around it. The easiest approach is to loosen the type safety for the base class static methods so that subclasses can just do what they want. That's where the any type comes in:

    class Base {
      static newX(this: new (...args: any) => Base, ...args: any) {
        return new this(...args);
      }
      static newY(this: { newX(...args: any): Base }, ...args: any) {
        return this.newX(...args);
      }
      toString() {
        return `${this.constructor.name}:${JSON.stringify(this)}`
      }
    }
    

    Now newX and newY will accept any arguments at all, in a fundamentally "unchecked" way compared to arguments of type unknown[]. And so now you can mark your subclass types as being whatever you want. Of course, you do need to mark those types, since the type inherited from the parent is way too loose:

    class A extends Base {
      constructor() {
        super();
      }
      static newX: () => A; // annotate type
      static newY: () => A; // annotate type
    }
    
    class B extends Base {
      n: number;
      constructor(n: number) {
        super();
        this.n = n;
      }
      static newX(n: number) {
        return new this(n);
      }
      static newY: (n: number) => B; // annotate type
    
    }
    class C extends Base {
      str: string;
      n: number;
      constructor(str: string, n: number) {
        super();
        this.str = str;
        this.n = n;
      }
      static newX: (str: string, n: number) => C; // annotate type    
      static newY(str: string, n: number) {
        return this.newX(str, n);
      }
    }
    

    It's not pretty, but it works. You might consider refactoring away from class hierarchies entirely if TypeScript's rules about what you are and are not allowed to do get in your way too much. But such refactoring is out of scope for this question as asked.

    Playground link to code