microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.47k stars 12.42k forks source link

`static abstract` methods and properties #34516

Open RyanCavanaugh opened 4 years ago

RyanCavanaugh commented 4 years ago

This is a continuation of #14600 which had two separate features proposed in the same issue (static members in interfaces and abstract static class members)

Search Terms

static abstract method property properties implement concrete

Suggestion

Currently, this code is illegal:

abstract class A {
    static abstract doSomething(): void;
}

// Should be OK
class B extends A {
    static doSomething() { }
}

// Should be an error; non-abstract class failed to implement abstract member
class C extends A {

}

It should be legal to have abstract static (static abstract?) members.

Use Cases

(what are they?)

Unresolved Questions

What calls of abstract static methods are allowed?

Let's say you wrote a trivial hierarchy

abstract class A {
    static abstract doSomething(): void;
}
class B extends A {
    static doSomething() { }
}

For an expression x.doSomething(), what are valid xs?

Option 1: All of them

Because this isn't generic in static members, we should simply allow all invocations of abstract static methods. Otherwise code like this would be illegal:

abstract class A {
  static abstract initialize(self: A): void;
  static createInstance() {
    const a = new this();
    this.initialize(a);
    return a;
  }
}

However, this means that TypeScript would miss straight-up crashes:

// Exception: 'this.initialize' is not a function
A.createInstance();

Option 2: None of them

Allowing crashes is bad, so the rule should be that static abstract methods simply don't exist from a type system perspective except to the extent that they enforce concrete derived class constraints:

abstract class A {
    static abstract doSomething(): void;
}
class B extends A {
    static doSomething() { }
}

// Error, can't call abstract method
A.doSomething();

// This call would work, but it'd still be an error
const Actor: typeof A = B;
Actor.doSomething();

function indirect(a: { doSomething(): void }) {
  a.doSomething();
}

// Error, can't use abstract method 'doSomething' to satisfy concrete property
indirect(A);
// OK
indirect(B);

This is unergonomic because it'd be impossible to write a function that dealt with an arbitrary complex constructor function without tedious rewriting:

abstract class Complicated {
    static abstract setup(): void;
    static abstract print(): void;
    static abstract ship(): void;
    static abstract shutdown(): void;
}

function fn(x: typeof Complicated) {
  // Error, can't call abstract method
  x.setup();
  // Error, can't call abstract method
  x.print();
  // Error, can't call abstract method
  x.ship();
  // Error, can't call abstract method
  x.shutdown();
}

We know this is a problem because people get tripped up by it constantly when they try to new an abstract class:

https://www.reddit.com/r/typescript/comments/bcyt07/dynamically_creating_instance_of_subclass/ https://stackoverflow.com/questions/57402745/create-instance-inside-abstract-class-of-child-using-this https://stackoverflow.com/questions/49809191/an-example-of-using-a-reference-to-an-abstract-type-in-typescript https://stackoverflow.com/questions/53540944/t-extends-abstract-class-constructor https://stackoverflow.com/questions/52358162/typescript-instance-of-an-abstract-class https://stackoverflow.com/questions/53692161/dependency-injection-of-abstract-class-in-typescript https://stackoverflow.com/questions/52358162/typescript-instance-of-an-abstract-class

For abstract constructor signatures, the recommended fix of using { new(args): T } is pretty good because a) you need to be explicit about what arguments you're actually going to provide anyway and b) there's almost always exactly one signature you care about, but for static abstract methods/properties this is much more problematic because there could be any number of them.

This also would make it impossible for concrete static methods to invoke abstract static methods:

abstract class A {
  static abstract initialize(self: A): void;
  static createInstance() {
    const a = new this();
    // Error
    this.initialize(a);
    return a;
  }
}

On the one hand, this is good, because A.createInstance() definitely does crash. On the other hand, this literally the exact kind of code you want to write with abstract methods.

One solution would be the existence of an abstract static method with a body, which would be allowed to invoke other abstract static methods but would be subject to invocation restrictions but not require a derived class implementation. This is also confusing because it would seem like this is just a "default implementation" that would still require overriding (that is the bare meaning of abstract, after all):

abstract class A {
    abstract static initialize() {
        console.log("Super class init done; now do yours");
    }
}
// No error for failing to provide `static initialize() {`, WAT?
class B extends A { }

An alternative would be to say that you can't call any static method on an abstract class, even though that would ban trivially-OK code for seemingly no reason:

abstract class A {
    static foo() { console.log("Everything is fine"); }
}
// Can't invoke, WAT?
A.foo();

Option 3: Indirection is sufficient

Why not just split the baby and say that the direct form A.doSomething() is illegal, but expr.doSomething() where expr is of type typeof A is OK as long as expr isn't exactly A.

This creates the dread inconsistency that a trivial indirection is sufficient to defeat the type system and cause a crash:

// Error; crash prevented!
A.doSomething();
const p = A;
// OK, crashes, WAT?
p.doSomething();

It's also not entirely clear what "indirection" means. Technically if you write

import { SomeStaticAbstractClass as foo } from "./otherModule";
foo.someAbstractMethod();

then foo isn't exactly the declaration of SomeStaticAbstractClass itself - it's an alias. But there isn't really anything distinguishing that from const p = A above.

Option 4: Indirection, but with generics

Maybe a trivial indirection as described in Option 3 isn't "good enough" and we should require you to use a constrained generic instead:

// Seems like you're maybe OK
function fn<T extends typeof A>(x: T) {
    x.doSomething();
}

// Good, OK
fn(B);
// A fulfills typeof A, fair enough, crashes, WAT?
fn(A);

This turns out to be a bad option because many subclasses don't actually meet their base class static constraints due to constructor function arity differences:

abstract class A {
    constructor() { }
    foo() { }
}

class B extends A {
    constructor(n: number) {
        super();
    }
    bar() { }
}

function fn<T extends typeof A>(ctor: T) {
    // Want to use static methods of 'ctor' here
}
// Error, B's constructor has too many args
fn(B);

This isn't even code we want people to write -- a generic type parameter used in exactly one position is something we explicitly discourage because it doesn't "do anything".

Option 5: Something else?

Anyone able to square this circle?

Checklist

My suggestion meets these guidelines:

Cosmic-Ryver commented 3 years ago

@olee the ability to specify a generic static interface on a class is an advantage of the static-implements proposal. However, the static-implements proposal falls short of delivering the key capability that has prevented this feature request's implementation since the beginning. To illustrate under my abstract/concrete static proposal syntax:

export type Event = {
  // ...
};

export abstract class Loggable {
  abstract static events: Event[];
  concrete static logEvents() {
    this.events.forEach(formatAndLog);
    function formatAndLog() {
      // ...
    }
  }
}

Here we are able to share reusable logic, the logEvents method, that depends on a static property that is only instantiated in inheriting concrete sub-classes. This can't be achieved with the static-implements proposal, which is why I advocate for a more comprehensive solution to the problem.

I would also argue that my solution not enabling generic static interfaces is not a "basic flaw" anymore than the static-implements proposal's inability to cover the above use case is a "basic flaw". They are solving two different problems, but happen to overlap in the ability to mandate implementation of static members in sub-classes. These proposals are actually compatible as far as I can tell. The only caveat would be that members declared abstract static or concrete static should satisfy any requirements imposed by an implements static statement.

olee commented 3 years ago

@GusBuonv no matter what, I do not see any chance in any proposal which would require defining a static contract in an abstract base class for the reasons I mentioned above (eg. extending a class from a library where you have no access to etc.) so I don't think we should try to continue in this direction.

@Wikiemol you are adding some good thoughts there, but I think you might be trying to overengineer the problem. In fact, the only thing which is required here is to enforce a kind of contract (interface) on the constructor of a class.


Actually I think your example with assigning a class to a typed constant already achieves this goal, just not in a way which would be really usable for developers:

interface IAnimal {
  readonly age: number;
}

interface IAnimalStatic {
  getBiomes(): string[];
}

class Lion implements IAnimal {
  public static getBiomes() {
    return ['savanna'];
  }

  public get age() {
    return 3;
  }
}
const _enforceLionStatic: IAnimalStatic = Lion;

So this is why I think the alternative solutions here would be to provide the means to enforce the same semantic as in the above example with the proposed static implements syntax:

class Lion implements IAnimal, static IAnimalStatic {
  // same as above
}

EDIT: I just noticed that in fact in https://github.com/microsoft/TypeScript/issues/33892#issuecomment-542440546 @RyanCavanaugh already summarized a few of the thoughts I had regarding this really nicely

Cosmic-Ryver commented 3 years ago

@olee Could you provide an example of how importing third party modules would break my proposal? There is no need under my proposal to modify the original abstract class.

abstract class A {
  concrete static useFoo() {
    if (this.Foo === undefined) {
      throw new Error()
    }
    console.log(this.Foo);
  }
  abstract static Foo: string;
}

class B extends A {
  static Foo = 'Hello World!';
}

B.useFoo();
// A.useFoo(); // CompilerError: concrete static method useFoo may not be accessed on abstract class A

Would compile to an equivalent of the following valid JavaScript:

class A {
  static useFoo() {
    if (this.Foo === undefined) {
      throw new Error()
    }
    console.log(this.Foo);
  }
}

class B extends A {
  static Foo = 'Hello World';
}

B.useFoo();

Class A could be defined in and exported from a third party module, and you can extend with B perfectly safely in your own code.

olee commented 3 years ago

@GusBuonv I think the issue is that I saw static abstract and implements static as two opposing proposals with only one of them being implemented. If it was like that, there would have been issues to implement various features. But if one considers this proposal only in the aspect of adding a static abstract modifier, I think there is no issue and what is proposed makes a lot of sense!

In fact, both could be combined easily like this:

interface IAnimal {
  readonly age: number;
}

interface IAnimalStatic {
  getBiomes(): string[];
}

abstract class Animal implements IAnimal, static IAnimalStatic {
  static abstract getBiomes(): string[];
  abstract get age(): number;
}

class Lion extends Animal {
  public static getBiomes() {
    return ['savanna'];
  }

  public get age() {
    return 3;
  }
}
Wikiemol commented 3 years ago

@olee You are probably right that I am over-engineering the problem haha.

Regardless of whether my specific proposal is good or not, for me the point is that, I feel like there is not much to be gained from this unless, in addition to forcing my class to implement static methods, the following are implemented (in order of importance)

  1. Be able to express and communicate using the type system that an interface is intended to be a static contract
  2. Given a static contract MyStaticContract, Be able to specify that a given type parameter of a type is intended to be an instance of a class which implements MyStaticContract without specifying the class explicitly. EDIT (And be able to use these static contract methods for its particular implementation on the instance)
  3. Be able to use type parameters of the implementing class as part of the contract definition.

For me, I probably wouldn't use this feature much if at least one of these things weren't part of the implementation. As many have already said, you can already force your class to implement static methods. But this doesn't really get you anywhere on its own (I think this is why Ryan has trouble understanding the use cases. There really aren't many if that's all there is to it!) I think if all we wanted was features that already existed, this wouldn't be so highly requested.

For me, the above three things are "really" what I want most. At the very least, I think 1 should be considered.

(EDIT Also, I think @GusBunov's solution does well for satisfying 1, but it doesn't really provide a way to do 2 or 3 that I can think of. It solves the this problem, but as far as I can tell it introduces another problem which is that it becomes very hard to abstract over these static methods if we ban being able to do A.method() for an abstract class A)

Cosmic-Ryver commented 3 years ago

@Wikiemol

I think @GusBunov's solution does well for satisfying 1, but it doesn't really provide a way to do 2 or 3 that I can think of. It solves the this problem, but as far as I can tell it introduces another problem which is that it becomes very hard to abstract over these static methods if we ban being able to do A.method() for an abstract class A.

You are correct. My proposal is solving a fundamentally different problem than 2 & 3. I'm focussed on the question @RyanCavanaugh raised here. The issue of static interfaces/contracts is wholly separate.

However, I do think these approaches (ie my proposal and the implements static proposal) pair extremely nicely. Expanding on @olee's example here, we can implement reusable generic static logic by combining both proposals:

interface IAnimal {
  readonly age: number;
}

interface IBiome {
    // ...
}

interface ITerrestrialBiomes extends IBiome {
  // ...
}

interface IAnimalStatic<T extends IBiome> {
  concrete getBiomes(): T[];
}

abstract class Mammal implements IAnimal, static IAnimalStatic<ITerrestrialBiomes> {
  static concrete getBiomes(): ITerrestrialBiomes[] {
    return [...this.biomes];
  }
  private static abstract biomes: ITerrestrialBiomes[];
  abstract get age(): number;
}

const savanna: ITerrestrialBiomes = {
  // ...
};

class Lion extends Mammal {
  private static biomes = [savanna];

  public get age() {
    return 3;
  }
}

Lion.getBiomes() // returns `[savanna]` typed as ITerrestrialBiomes[];

EDIT: For those keeping up with the conversation, I realized my example here cannot work in practice, and I've updated the comment accordingly.

Wikiemol commented 3 years ago

@GusBuonv Its a nice solution for specifically problem 1, but I suppose my point is that not only does it not solve problem 2, it makes it actively difficult to implement 2 in the future.

I would argue that being able to abstract over these things is the primary reason we use abstract classes, and is intimately related with @RyanCavanaugh's problems with Option 2. Consider, for example, the following situation using your example

function getBiomes(mammals: Array<Mammal>): Array<ITerrestrialBiomes[]> {
    return mammals.map(x => /*???*/)
}

The question is, what do we put in map function? We cannot call x.getBiomes() since getBiomes is static. We cannot call Lion.getBiomes() since obviously, not all of the x's are necessarily Lions

My first instinct would be something like this (changing getBiomes to optionally take an instance)

function getBiomes(mammals: Array<Mammal>, mammalType: typeof Mammal): Array<ITerrestrialBiomes[]> {
    return mammals.map(x => mammalType.getBiomes(x))
}

But this will give us a compiler error since we aren't sure that mammalType is concrete. Moreover, this doesn't make much sense in the first place, we need all members of the array to be the same type.

But Note that this is currently not allowed

function getBiomes<T extends Mammal>(mammals: Array<T>, mammalType: typeof T): Array<ITerrestrialBiomes[]> {
    return mammals.map(x => mammalType.getBiomes(x))
}

This will give us a compiler error along the lines of "T is a type but is being used as a value" on typeof T. Even if it were allowed, in this specific case, we still cannot know if typeof T has implemented the field biomes or not.

With abstract methods of any kind, we want to be able to do things like the above, but its hard to do that if we make the static fields themselves abstract. This is primarily the type of thing we want to be able to do with abstract classes, if we can't do this type of thing, then what is the point?

Ryan's solution fixes this problem, but then we lose the pro of your solution, which is that we all want to be able to explicitly declare that an abstract contract needs to be implemented statically.

Cosmic-Ryver commented 3 years ago

@Wikiemol You would use the following:

function getBiomes(mammals: Array<concrete typeof Mammal>): Array<ITerrestrialBiomes[]> {
    return mammals.map(x => x.getBiomes());
}

EDIT: Added the missing concrete modifier to typeof Mammal. See my original proposal for its meaning in this context.

More generally you could use the following:

function getBiomes<B extends IBiome, T extends concrete IAnimalStatic<B>>(animals: Array<T>): Array<B[]> {
    return animals.map(x => x.getBiomes());
}
Wikiemol commented 3 years ago

@GusBuonv This is not quite the same, here you are calling getBiomes() on an "array of classes", not an array of instances of mammals. This level of abstraction can already be accomplished in existing typescript.

I.e. we can already have functions which take instances of things (including classes) which satisfy some constraints as the TS maintainers have already pointed out.

abstract class Mammal {
  abstract get age(): number;
}

abstract class BiomeAnimal {
     abstract getBiomes(): string[];
}

const Lion: BiomeAnimal = class extends Mammal {
    static getBiomes() {
        return ["savannah"]
    }
    public get age() {
        return 3;
    }
}

then we can do, as you have essentially done

function getBiomes(mammals: Array<BiomeAnimal>): Array<string[]> {
    return mammals.map(x => x.getBiomes());
}

The only difference here is that we have separated the abstract static part of Mammal into a separate class.

I am arguing that what is not currently expressible in typescript at all is something like

function sort<T extends Mammal : Comparable<T>>(mammals: Array<T>, comparator: Comparable<T>): Array<string[]> {
    return mammals.sort((x, y) => comparator.compare(x, y));
}

Where, by implementing Comparable, we are saying for example that the collection of all Ts forms an ordered set of animals (by comparing age for example), as opposed to saying that each individual T is comparable to any other T that implements Comparable in whatever way they choose.

I.e. we are asserting that all of the instances of T have statically implemented Comparable, and moreover that the argument comparator is that specific implementation

comparator essentially represents some information about the type of all of the Ts in the array.

Cosmic-Ryver commented 3 years ago

@Wikiemol I agree that your example isn't supported, but supporting that use case is, in my mind, an entirely different feature request than abstract static support. Combining static implements with abstract static/concrete static declarations satisfies the nearly all of use cases brought up in this thread over the years, and it does so with easy to understand syntax that mirrors what is already in the language.

olee commented 3 years ago

I fully agree with @GusBuonv and I think we should try to focus on the actual proposal which I agree would be really nice to have.

Shereef commented 2 years ago

It's 2022 and Q1 is almost over any update on this ? What is the timeline for it ?

Rigidity commented 2 years ago

This would be great to have, looking forward to updates on this feature request.

RyanCavanaugh commented 2 years ago

Can people engage with the OP? Which proposed behavior do you want to see, and why?

Rigidity commented 2 years ago

I would love to be able to do things like this:

abstract class Base {
    static abstract fromInt(value: number): this;
}

class Impl extends Base {
    public static fromInt(value: number): Impl {
        return new Impl(); // just placeholder for this example
    }
}

and like this:

abstract class Tile {
    public static abstract name: string;
    public static abstract texture: string;
    public static abstract solid: boolean;
}

class Rock extends Tile {
    public static name = "Rock";
    public static texture = "rock.png";
    public static solid = true;
}

As a side-change, and since this ensures something known at compile-time, this would allow this with the previous example:

const rock = new Rock();
const tile: Tile = rock;
const tileName = tile.name; // known static property, so it can be used here non-statically as well

This would help me out a lot in practice, as well. For example, I wrote https://github.com/rigidity/bls-signatures, and in doing so I had to copy the definition of several static methods among every subclass of Field and had no way to know that they existed on the superclass, or use them with property access. There was also no type checking on those methods existing (which they had to in the library), which is literally the whole purpose of TypeScript. I ended up duplicating so much code because of this, but with this feature the code would be much cleaner and more statically typed.

For the first feature, overriding static members, I think there's no reason that they can't be added, since it's just a compile-time requirement like anything else in the language. It also adds no code to the underlying JavaScript.

For the second, I can understand not having static fields or methods be usable on instances, but if it's possible, these two combined can be very powerful and clean code up a lot.

I hope that this can be considered!

Rigidity commented 2 years ago

Any update or thoughts on this?

RyanCavanaugh commented 2 years ago

@Rigidity no, the problems with all the possible approaches outlined in the OP remain effectively unsolved

Rigidity commented 2 years ago

If you know that every subclass of a class with an abstract static method will implement it, you can assume it exists.

Being able to call new this and use abstract methods in a static initializer method would be incredibly useful for my project as well, and here is my proposition for how that would work:

Am I missing something here? This seems possible to do. I'd be willing to look into implementing and unit testing this myself if that would help...

RyanCavanaugh commented 2 years ago

If you attempt to call it on the super-class...

OK, but how does that work in practice?

Let's say you took something that could operate on a class with a static create method

class Foo {
  abstract static create(): Foo;
}
class Derived extends Foo {
  static create() {
    return new Derived();
  }
}

function fn(x: typeof Foo) {
  return x.create();
}

fn(Foo);

Where's the type error in this program that crashes, and why?

thw0rted commented 2 years ago

Ryan, last year there were some comments proposing a concrete keyword. It's been a long time, and I didn't fully re-read the many-page conversation, so this is based on fuzzy memory and a quick skim, but: I believe the error would be at return x.create() because x is typeof Foo not concrete typeof Foo. typeof Foo would mean Foo without abstract static members, basically. If you declared the parameter as x: concrete typeof Foo, then calling fn(Foo) would be an error because Foo does not implement all abstract static methods.

As an aside: doesn't a class with abstract members have to be abstract itself?

Zamralik commented 2 years ago

I would say abstract static makes more sense to be in line with typescript requiring TS specific keywords (public, protected, private) to be put before the static keyword.

Lamby777 commented 2 years ago

Dear god, don't make me write a singleton class as a workaround 😭😭😭

trickypr commented 2 years ago

I think that a modified version of Option 2 on the original proposal would be the closest to what a programmer would expects. I do not think that it should be valid to call any static members on an abstract class.

abstract class Base {
    abstract static getName(): string
    static logName() {
        console.log(this.getName())
    }
}

Base.getName() // Should error: Cannot call static method 'getName' on an abstract class
Base.logName() // Should error: Cannot call static method 'logName' on an abstract class

This will ensure that the function should never cause a runtime error without knowing the contents of the function (e.g. the compiler only has a type definition file). However, this implementation is impossible to use in any helpful manner:

abstract class Base {
    abstract static getName(): string
    static logName() {
        console.log(this.getName())
    }
}

class Subclass extends Base {
    static getName(): string {
        return 'Subclass'
    }

    static logName() {
        console.log(this.getName())
    }
}

function doMagic(baseSubclass: typeof Base) {
    let name = baseSubclass.getName() // Should error: Cannot call static method 'getName' on an abstract class
    let instance = new baseSubclass() // Already errors: Cannot create an instance of an abstract class.

    // Do something here....
}

doMagic(Subclass)
doMagic(Base)

The main problem here is the limitation of typeof Base. This is still the abstract class, when the programmer intends it to be a class that extends Base. There was a proposal early in the thread to add Concrete<typeof T> which would handle this expectation. I like this idea, although Extends<typeof T> might be easier to read.

abstract class Base {
    abstract static getName(): string
    static logName() {
        console.log(this.getName())
    }
}

class Subclass extends Base {
    static getName(): string {
        return 'Subclass'
    }

    static logName() {
        console.log(this.getName())
    }
}

function doMagic(baseSubclass: Extends<typeof Base>) {
    let name = baseSubclass.getName()
    let instance = new baseSubclass()

    // Do something here....
}

doMagic(Subclass)
doMagic(Base) // Should error: Base is an abstract class. Try entering one of its children

Would this be a potential solution, or am I missing something?

Problems and potential points for input

dead-claudia commented 2 years ago

I have a suggestion of my own to reconcile it, going off option 5 by taking the type-driven part of option 4 and tweaking it to both eliminate the failure modes and the need for generics while ensuring they can still be fully manipulated by the type system and minimizing breakage: abstract properties.

Here's how abstract properties would work:

This also gives people the ability to allow some methods to be exposed if only some abstract properties are implemented while disallowing others, if they really wanted to.

To take the examples from OP:

Click to expand ``` /* Subtyping */ abstract class A { static abstract doSomething(): void; } // OK class B extends A { static doSomething() { } } // Error class C extends A { } // Error: property is abstract and may be `undefined` A.doSomething() // OK B.doSomething() /* Instance access */ // Error: `this.initialize` may be `undefined` abstract class A { static abstract initialize(self: A): void; static createInstance() { const a = new this(); this.initialize(a); return a; } } // Error, but not in `A` abstract class A { static abstract initialize(self: A): void; static createInstance(this: Concrete) { const a = new this(); this.initialize(a); return a; } } class B extends A { // Error: A is not assignable to B static initialize(self: B): void { /* ... */ } } // Correct, but suboptimal abstract class A> { static abstract initialize>(self: I): void; static createInstance(this: ConcreteClass) { const a = new this(); this.initialize(a as I); return a; } } class B extends A { // OK static initialize(self: B): void { /* ... */ } } // Best: just use an abstract instance method and guard `this` to be the // function you want it to be (doable with current nightly) abstract class A { abstract initialize(): void; static createInstance(this: new () => A) { const a = new this(); a.initialize(); return a; } } class B extends A { // OK initialize(): void { /* ... */ } } // The 'this' context of type 'typeof A' is not assignable to method's 'this' of type 'new () => A'. // Cannot assign an abstract constructor type to a non-abstract constructor type.(2684) A.createInstance(); // OK B.createInstance(); /* Indirection */ abstract class A { static abstract doSomething(): void; } class B extends A { static doSomething() { } } // Error: `doSomething` is abstract A.doSomething(); // OK, non-abstract properties are assignable to abstract properties const Actor: typeof A = B; // Error: `doSomething` is abstract Actor.doSomething(); function indirect(a: { doSomething(): void }) { a.doSomething(); } // Error: `doSomething` is abstract and thus is not assignable to a // non-abstract property. indirect(A); // OK indirect(B); abstract class Complicated { static abstract setup(): void; static abstract print(): void; static abstract ship(): void; static abstract shutdown(): void; } // Wrong function fn(x: typeof Complicated) { // Error, can't call abstract method x.setup(); // Error, can't call abstract method x.print(); // Error, can't call abstract method x.ship(); // Error, can't call abstract method x.shutdown(); } // Right, and the rewrite is trivial function fn(x: Concrete) { // OK x.setup(); // OK x.print(); // OK x.ship(); // OK x.shutdown(); } /* Simple invocation */ abstract class A { static foo() { console.log("Everything is fine"); } } // OK: not abstract property and `this` need not be concrete A.foo(); abstract class A { static abstract doSomething(): void; } class B extends A { static doSomething() { } } / Seems like you're maybe OK function fn(x: Concrete) { x.doSomething(); } // OK fn(B); // Error: `A` has abstract property `doSomething` fn(A); abstract class A { constructor() { } foo() { } } class B extends A { constructor(n: number) { super(); } bar() { } } function fn(x: Concrete) { // Want to use static methods of 'ctor' here } // OK fn(B); ```

Pros:

  • Entirely prevents crashes
  • Reasonably ergonomic in most common uses
  • Improved flexibility: one can freely use an abstract class anywhere their non-abstract methods are allowed
  • Functions can require only some abstract static methods to be defined without requiring all to be defined
  • Still reasonably intuitive when you think of them in terms of properties
  • Entirely non-breaking

Cons:

  • It's complicated
  • It's necessarily inconsistent with abstract instance properties
  • Requires explicit this types for any method that can access such abstract properties.
  • The case of factories invoking static methods is non-trivial and requires a decent amount of type hackery (though abstract instance methods and simple subclassing should be preferred over this pattern anyways)
tin-pham commented 2 years ago

Still waiting

clounie commented 2 years ago

Interesting to note / might provide some helpful contrast - Dart is chatting about the same problem https://github.com/dart-lang/language/issues/356#issuecomment-1227522887

tempusdave commented 1 year ago

Three years later and no action? I can't believe this wasn't baked-in in when TypeScript was designed...

RyanCavanaugh commented 1 year ago

Comments to the tune of "I can't believe this isn't done yet" will be hidden; there are real problems outlined in the OP and other comments that need constructive feedback.

owl-from-hogvarts commented 1 year ago

Hello. I would like to provide quick answer for situations from op's question.

Quick recall of the context:

Let's say you wrote a trivial hierarchy

abstract class A {
    static abstract doSomething(): void;
}
class B extends A {
    static doSomething() { }
}

Because this isn't generic in static members, we should simply allow all invocations of abstract static methods. Otherwise code like this would be illegal:

and want to do:

abstract class A {
  static abstract initialize(self: A): void;
  static createInstance() {
    const a = new this();
    this.initialize(a);
    return a;
  }
}

Solution

Let's define type InstantiableSubclasses which would literally mean: static side of all subclasses of abstract class we can use with new operator

Valid behavior would be to throw error that initialize could NOT be called on this because this refers to typeof A and we can't call abstract methods.

abstract class A {
  static abstract initialize(self: A): void;
  static createInstance() {
    const a = new this();
    this.initialize(a); // ERROR: initialize can't be called on type `typeof A`, initialize is abstract
    return a;
  }
}

But a user may narrow this by explicitly providing it like this:

abstract class A {
  static abstract initialize(self: A): void;
  static createInstance(this: InstantiableSubclasses<A>) {
    const a = new this();
    this.initialize(a); // VALID
    return a;
  }
}

A.createInstance() // ERROR: initialize could not be called: 'this' type mismatch

This looks like we provide default implementation for method, but only allow it's usage in fully concrete context


abstract class Complicated {
    static abstract setup(): void;
    static abstract print(): void;
    static abstract ship(): void;
    static abstract shutdown(): void;
}

// does not allow to pass Complicated itself
function fn(x: InstantiableSubclasses<Complicated>) {
  // will work
  x.setup();
  x.print();
  x.ship();
  x.shutdown();
}
TiesWestendorp commented 1 year ago

Is there a status update on this? I've run into this issue when trying to replicate some of our back-end to the front-end, since abstract static methods are used there, I would like to duplicate this logic in a similar way using TS. A small sample of how I would want it to look:

export default abstract class Category extends Period {
  year: number;
  number: number;

  constructor(start: Date, stop: Date, year: number, number: number) {
    super(start, stop);
    this.year = year;
    this.number = number;
  }

  abstract static periodsInYear(year: number): number;
  abstract static nth(year: number, number: number): Category;

  static forYear(year: number): Category[] {
    return [...Array(this.periodsInYear(year)).keys()].map(number => this.nth(year, number));
  }

  previous(): Category {
    if (this.number > 1) {
      return Category.nth(this.year, this.number-1);
    }
    return Category.nth(this.year-1, Category.periodsInYear(this.year-1));
  }

  next(): Category {
    if (this.number < Category.periodsInYear(this.year)) {
      return Category.nth(this.year, this.number+1);
    }
    return this.constructor.nth(this.year+1, 1);
  }
};

Where each concrete subclass of Category would implement the static methods periodsInYear and nth, but not need to define their own implementations of forYear, previous and next. I understand there are some issues with the above, and changing the necessary parts to this.constructor or some generic Extends<...> type would be no real issue for me syntactically.

The discussion is pretty long though, and I've failed to understand the gist of the problem, and if and when this will be implemented.

Edit: The approach suggested here (https://github.com/microsoft/TypeScript/issues/34516#issuecomment-642087219) satisfies my use case, but it somehow feels a bit hacky, and doesn't provide type checking in the child class to the extent that I want it: it doesn't signal that the "abstract static" methods aren't defined, for example. I had to use the "trick" from Kamil's answer to https://stackoverflow.com/questions/13955157/how-to-define-static-property-in-typescript-interface to actually make the type checker succeed.

QuintenPeulingCodesharpNl commented 1 year ago

Ideally I'd like to access public abstract static variables with a public static function in the base class. Would this be possible if this issue gets resolved?

Distortedlogic commented 1 year ago

bump

Oaphi commented 1 year ago

@Distortedlogic Please do not "bump" issues if you do not have anything of substance to add, this only serves to annoy everyone who are subscribed to the issue (as GitHub sends emails for each and every comment made) and to make the discussion unreadable.

@QuintenPeulingCodesharpNl the issue with being able to use abstract static class fields inside concrete abstract class's methods is amongst the unresolved questions outlined in the OP, unfortunately. Depending on what the team settles with, it might or might not be possible. The problem with allowing abstract fields to be used in concrete static methods of an abstract (base) class is that the compiler will count blatant runtime errors as valid code (if the programmer, say, attempts to call the method on the abstract class directly). The OP has potential solutions to that, but all have significant drawbacks that seem to be still in discussion.

gamesaucer commented 3 months ago

Establishing the need for a type whose members can only be concrete classes:

Click to open It seems to me that the only reason that this is a problem is that abstract classes exist concretely. If you define an abstract class, you can refer to that abstract class as an entity: ```ts abstract class MyClass {} function fn (X: typeof MyClass): MyClass { return new X() } fn(MyClass) ``` This is still desirable in a few ways, because it lets you access non-abstract static properties on the class, and changing this would break existing TypeScript code: ```ts abstract class MyClass { static concreteMember = 'Hello World' } function fn (X: typeof MyClass): string { return X.concreteMember } fn(MyClass) ``` Basically, to allow abstract static members without any ugly workarounds, you need to treat it as non-existent, but to allow concrete static members, you need to treat it as a concrete object in and of itself. This brings me to an alternate analysis of the typing involved: why not consider what can be *constructed* as `MyClass`? ```ts abstract class MyClass {} class MySubClass extends MyClass {} function fn (X: new() => MyClass): MyClass { return new X() } ``` The following code doesn't compile because `typeof MyClass` doesn't fit constraint `new () => MyClass`. ```ts fn(MyClass) ``` However, this code runs fine: ```ts fn(MySubClass) ``` This by itself doesn't solve the issue, because `concreteMember` does not exist on `new () => MyClass`: ```ts abstract class MyClass { static concreteMember = 'Hello World' } function fn (X: new() => MyClass): string { return X.concreteMember } ``` At a glance, this is a bit odd. If `X` constructs an instance of `MyClass`, doesn't nominal typing strictly require that `X` is `MyClass` or a subclass thereof? But that's not exactly true: ```ts const fakeConstructor: new() => MyClass = new Proxy(class {}, { construct () { return new MySubClass() } }) ``` It seems proxies are standing in the way this time. But regardless of whether that's worth tackling, `new () => MyClass` is pretty cumbersome, especially seeing as it should be more fully written as `new (...args: any[]) => MyClass`... so this really shouldn't be the method to do this anyway. However, it *has* allowed me to home in on what's needed here: a way to check whether something is a concrete subclass of a given type. e.g. ```ts function fn (X: concrete typeof MyClass): string { return X.concreteMember } ``` This is not the only option. Something like `implemented typeof MyClass` could be used instead, or `Concrete`, or even `Concrete`. I don't feel strongly one way or another about this, and I'm certain that if this is implemented the TypeScript team will select an appropriate option.

Establishing the need a three-way abstractness distinction between static methods:

Click to open With this, the core problem may be solved, but this causes a different issue: ```ts abstract class MyClass { abstract static foo (): string static bar (): string { return this.foo() } } ``` This should obviously cause an error. However, if the goal is to provide a default implementation that subclasses can inherit from, it's not necessarily a flawed idea. What you'd *want* this to do is create a property `bar` that only exists on subclasses of `MyClass`. But this can't be done as-is, because that would change how static methods already work. So a new language feature would need to be introduced, e.g.: ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } ``` Here, `bar` does not exist on `MyClass`. It only exists on concrete subclasses of `MyClass`. This would also make it possible to do the following: ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } abstract class MySubClass extends MyClass { abstract static baz (): string { return this.bar() } } ``` It might seem strange to allow an implementation on an abstract method, but I'm choosing this approach for a reason: 1. There isn't any behavioural difference between a hypothetical implemented `abstract` instance method and a concrete instance method, the `static` case is special; unlike for an instance method, a `static` method is directly accessible on the class without needing to instantiate it, whereas an `abstract static` method would not be. 2. It more clearly signals the intent of the programmer than alternatives like `concrete` or `implemented`; `abstract` signals that something about its declaration is incomplete, to be decided by a future context. It's just that in this case what it lacks is not an implementation, but an appropriately concrete value for `this`. However, this is only my opinion. There might be good arguments for options like `concrete`, `default` etc. as well.

Showing examples of this system in action:

Click to open Here are some examples of how this would interact with the type system. Two examples that show that an abstract class cannot be instantiated: ```ts abstract class MyClass {} function fn (X: typeof MyClass): MyClass { return new X() // Compiler error: Cannot create an instance of an abstract class. } fn(MyClass) ``` ```ts abstract class MyClass {} function fn (X: concrete typeof MyClass): MyClass { return new X() } fn(MyClass) // Argument of type 'typeof MyClass' is not assignable to parameter of type 'concrete typeof MyClass'. ``` An example that shows that a concrete class can be instantiated: ```ts abstract class MyClass {} class MySubClass extends MyClass {} function fn (X: concrete typeof MyClass): MyClass { return new X() } fn(MySubClass) // Runs without errors ``` Two examples that show that an abstract static method is inaccessible on the class on which it is declared: ```ts abstract class MyClass { abstract static foo (): string static bar (): string { return this.foo() // Compiler error: Property 'foo' does not exist on type 'typeof MyClass'. } } ``` ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } console.log(MyClass.bar()) // Compiler error: Property 'bar' does not exist on type 'typeof MyClass'. ``` An example that shows that an abstract static method is not accessible in non-concrete subclasses: ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } abstract class MySubClass extends MyClass { static baz (): string { return this.bar() // Compiler error: Property 'bar' does not exist on type 'typeof MySubClass'. } } ``` An example that shows how everything works out if this feature is used correctly: ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } class MySubClass extends MyClass { static foo (): string { return 'Hello World' } static baz (): string { return this.bar() } } console.log(MyClass.baz()) // Logs 'Hello World' to the console without errors. ``` An example that shows that abstract static methods must be implemented in concrete subclasses if a default implementation was not provided: ```ts abstract class MyClass { abstract static foo (): string abstract static bar (): string { return this.foo() } } class MySubClass extends MyClass { // Compiler error: Non-abstract class 'MySubClass' does not implement all abstract members of 'MyClass'. static baz (): string { return this.bar() } } ``` For that last case, perhaps a slightly different error message should be generated, though. For example: `Non-abstract class 'MySubClass' must implement abstract static members of 'MyClass' that lack an implementation.`

I think this solves all the issues in the OP? It at least provides a solution to whether abstract fields are allowed to be used in static methods.

TL;DR:

  1. Something like Concrete<typeof MyClass> / concrete typeof myclass is needed to know which static methods can be called.
  2. You can use abstract fields in static methods by making the static methods abstract in their usage but concrete in their definition, either by allowing abstract static methods to have a method body or by changing the two-way distinction (none)/abstract into a three-way distinction between (none)/default/abstract or such.

The actual terms I've chosen to represent these language features don't matter much, what matters is the idea behind them, which I think is sound in both concept and usage (though I am open to being proven wrong).