I have a number of class
es 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
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 number
s. 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'tThe 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 notThe 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.